// @ts-nocheck import type { DesktopExportPdfInput, DesktopExportPdfResult } from '@open-design/sidecar-proto'; import express from 'express'; import multer from 'multer'; import JSZip from 'jszip'; import { execFile, spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; import fs from 'node:fs'; import os from 'node:os'; import net from 'node:net'; import { defaultScenarioPluginIdForProjectMetadata, PLUGIN_SHARE_ACTION_PLUGIN_IDS, } from '@open-design/contracts'; import { composeSystemPrompt, renderCodexImagegenOverride, shouldRenderCodexImagegenOverride, } from './prompts/system.js'; import { expandHomePrefix, resolveProjectRelativePath } from './home-expansion.js'; import { createCommandInvocation } from '@open-design/platform'; import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto'; import { buildLiveArtifactsMcpServersForAgent, checkPromptArgvBudget, checkWindowsCmdShimCommandLineBudget, checkWindowsDirectExeCommandLineBudget, detectAgents, getAgentDef, isKnownModel, applyAgentLaunchEnv, resolveAgentLaunch, sanitizeCustomModel, spawnEnvForAgent, } from './agents.js'; import { migrateLegacyDataDirSync } from './legacy-data-migrator.js'; import { consumedImportNonces, getDesktopAuthSecret, isDesktopAuthGateActive, isDesktopAuthRegistered, pruneExpiredImportNonces, resetDesktopAuthForTests, setDesktopAuthSecret, signDesktopImportToken, verifyDesktopImportToken, } from './desktop-auth.js'; export { isDesktopAuthGateActive, isDesktopAuthRegistered, resetDesktopAuthForTests, setDesktopAuthSecret, signDesktopImportToken, verifyDesktopImportToken, } from './desktop-auth.js'; import { findSkillById, listSkills, splitDerivedSkillId } from './skills.js'; import { validateLinkedDirs } from './linked-dirs.js'; import { installFromTarget, uninstallById, sanitizeRepoName } from './library-install.js'; import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './native-folder-dialog.js'; import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js'; import { syncCommunityPets } from './community-pets-sync.js'; import { createUserDesignSystem, deleteUserDesignSystem, LEGACY_DESIGN_SYSTEM_ARTIFACTS, linkUserDesignSystemProject, listDesignSystems, listUserDesignSystemFiles, listUserDesignSystemRevisions, readDesignSystem, readDesignSystemPackageInfo, readUserDesignSystemFile, resolveDesignSystemAssets, updateUserDesignSystem, updateUserDesignSystemRevisionStatus, } from './design-systems.js'; import { createDesignSystemGenerationJobStore } from './design-system-generation-jobs.js'; import { applyDiffReviewDecisionToCwd, applyPlugin, defaultBundledRoot, doctorPlugin, FIRST_PARTY_ATOMS, getInstalledPlugin, getSnapshot, installFromLocalFolder, installPlugin, isDiffReviewSurfaceId, listInstalledPlugins, listIterationsForRun, MissingInputError, pluginPromptBlock, pruneExpiredSnapshots, readPluginLockfile, registerBuiltInAtomWorkers, registerBundledPlugins, registryRootsForDataDir, resolvePluginSnapshot, runPipelineForRun, runStageWithRegistry, startSnapshotGc, uninstallPlugin, } from './plugins/index.js'; import { marketplaceManifestUrlForRegistry, marketplaceRegistryIdFromUrl, } from './plugins/marketplaces.js'; import { getSurface, listSurfacesForProject, listSurfacesForRun, prefillProjectSurface, respondSurface as respondSurfaceRow, revokeProjectSurface, } from './genui/index.js'; import { buildMemoryTree, composeMemoryBody, deleteMemoryEntry, extractFromMessage, listMemoryEntries, maskMemoryExtractionConfig, memoryDir, memoryEvents, readMemoryConfig, readMemoryEntry, readMemoryIndex, updateMemoryTreeNode, upsertMemoryEntry, writeMemoryConfig, writeMemoryIndex, } from './memory.js'; import { clearExtractions as clearMemoryExtractions, listExtractions as listMemoryExtractions, removeExtraction as removeMemoryExtraction, } from './memory-extractions.js'; import { extractMemoryFromConnectors, suggestMemoryFromConnectors, } from './memory-connectors.js'; import { attachAcpSession } from './acp.js'; import { attachPiRpcSession } from './pi-rpc.js'; import { applyAutomationProposal, createAutomationProposal, getAutomationProposal, listAutomationProposals, rejectAutomationProposal, } from './automation-proposals.js'; import { getAutomationSourcePacket, ingestAutomationSource, listAutomationSourcePackets, } from './automation-ingestions.js'; import { ingestRoutineConnectorEvolution } from './automation-routine-evolution.js'; import { createClaudeStreamHandler } from './claude-stream.js'; import { diagnoseClaudeCliFailure } from './claude-diagnostics.js'; import { loadCritiqueConfigFromEnv } from './critique/config.js'; import { reconcileStaleRuns } from './critique/persistence.js'; import { runOrchestrator } from './critique/orchestrator.js'; import { createRunRegistry } from './critique/run-registry.js'; import { handleCritiqueInterrupt } from './critique/interrupt-handler.js'; import { handleCritiqueArtifact } from './critique/artifact-handler.js'; import { getCritiqueMetrics, register } from './metrics/index.js'; import { readConformanceHistory } from './critique/conformance-history.js'; import { evaluateRollout } from './critique/ratchet.js'; import { isCritiqueEnabled, parseEnvEnabled, parseRolloutPhase, type SkillCritiquePolicy, } from './critique/rollout.js'; import { narrowProjectCritiqueOverride } from './critique/spawn-inputs.js'; import { createCopilotStreamHandler } from './copilot-stream.js'; import { createJsonEventStreamHandler } from './json-event-stream.js'; import { classifyAgentAuthFailure, cursorAuthGuidance } from './runtimes/auth.js'; import { createQoderStreamHandler } from './qoder-stream.js'; import { subscribe as subscribeFileEvents } from './project-watchers.js'; import { renderDesignSystemPreview } from './design-system-preview.js'; import { renderDesignSystemShowcase } from './design-system-showcase.js'; import { createChatRunService } from './runs.js'; import { deriveRunErrorCode, runResultFromStatus } from './run-result.js'; import { reportRunCompletedFromDaemon } from './langfuse-bridge.js'; import { createAnalyticsService, newInsertId, readAnalyticsContext, readPublicConfigResponse, } from './analytics.js'; import { agentIdToTracking, deriveConfigureGlobals, } from '@open-design/contracts/analytics'; import { redactSecrets, testAgentConnection, testProviderConnection, validateBaseUrl, validateBaseUrlResolved, } from './connectionTest.js'; import { listProviderModels } from './providerModels.js'; import { importClaudeDesignZip } from './claude-design-import.js'; import { defaultBaseUrlForFinalizeProtocol, finalizeDesignPackage, FinalizePackageLockedError, FinalizeUpstreamError, isFinalizeProviderProtocol, } from './finalize-design.js'; import { listPromptTemplates, readPromptTemplate } from './prompt-templates.js'; import { buildDocumentPreview } from './document-preview.js'; import { lintArtifact, renderFindingsForAgent } from './lint-artifact.js'; import { loadCraftSections } from './craft.js'; import { stageActiveSkill } from './cwd-aliases.js'; import { buildDesktopPdfExportInput } from './pdf-export.js'; import { generateMedia } from './media.js'; import { listElevenLabsVoiceOptions } from './elevenlabs-voices.js'; import { searchResearch, ResearchError } from './research/index.js'; import { renderResearchCommandContract } from './prompts/research-contract.js'; import { openBrowser } from './browser-open.js'; import { AUDIO_DURATIONS_SEC, AUDIO_MODELS_BY_KIND, IMAGE_MODELS, MEDIA_ASPECTS, MEDIA_PROVIDERS, VIDEO_LENGTHS_SEC, VIDEO_MODELS, } from './media-models.js'; import { readMaskedConfig, writeConfig } from './media-config.js'; import { deleteMediaTask, getMediaTask, insertMediaTask, listMediaTasksByProject, listRecentMediaTasks, reconcileMediaTasksOnBoot, updateMediaTask, } from './media-tasks.js'; import { MCP_TEMPLATES, buildAcpMcpServers, buildClaudeMcpJson, buildOpenCodeMcpConfigContent, isManagedProjectCwd, readMcpConfig, writeMcpConfig, } from './mcp-config.js'; import { beginAuth, exchangeCodeForToken, PendingAuthCache, refreshAccessToken, } from './mcp-oauth.js'; import { clearToken, getToken, isTokenExpired, readAllTokens, setToken, } from './mcp-tokens.js'; import { agentCliEnvForAgent, readAppConfig, readPluginEnvKnobs, writeAppConfig } from './app-config.js'; import { OrbitService, formatLocalProjectTimestamp, renderOrbitTemplateSystemPrompt } from './orbit.js'; import { buildOrbitNoLiveArtifactSummary } from './orbit-agent-summary.js'; import { RoutineService, validateSchedule as validateRoutineSchedule, validateTarget as validateRoutineTarget, } from './routines.js'; import { buildMcpInstallPayload } from './mcp-install-info.js'; import { createDiagnosticsExportHandler } from './diagnostics-export.js'; import { DIAGNOSTICS_EXPORT_PATH } from '@open-design/diagnostics'; import { buildProjectArchive, buildBatchArchive, decodeMultipartFilename, deleteProjectFile, detectEntryFile, ensureProject, isSafeId, listFiles, mimeFor, parseByteRange, projectDir, readProjectFile, renameProjectFile, removeProjectDir, resolveProjectDir, sanitizeName, searchProjectFiles, resolveProjectDir, resolveProjectFilePath, writeProjectFile, } from './projects.js'; import { validateArtifactManifestInput } from './artifact-manifest.js'; import { ArtifactPublicationBlockedError } from './artifact-publication-guard.js'; import { readCurrentAppVersionInfo } from './app-version.js'; import { appendMessageAgentEvent, appendMessageStatusEvent, deleteConversation, deletePreviewComment, deleteProject as dbDeleteProject, deleteTemplate, getConversation, getDeployment, getDeploymentById, getProject, getTemplate, insertConversation, insertProject, insertRoutine, insertRoutineRun, insertTemplate, findTemplateByNameAndProject, updateTemplate, listProjectsAwaitingInput, listConversations, listDeployments, listLatestProjectRunStatuses, listMessages, listPreviewComments, listProjects, listRoutines, listRoutineRuns, listTabs, listTemplates, getLatestRoutineRun, getRoutine, deleteRoutine as dbDeleteRoutine, openDatabase, setTabs, updateConversation, updatePreviewCommentStatus, updateProject, updateRoutine, updateRoutineRun, upsertDeployment, upsertMessage, upsertPreviewComment, } from './db.js'; import { createLiveArtifact, deleteLiveArtifact, ensureLiveArtifactPreview, getLiveArtifact, LiveArtifactRefreshLockError, LiveArtifactStoreValidationError, listLiveArtifacts, listLiveArtifactRefreshLogEntries, readLiveArtifactCode, recoverStaleLiveArtifactRefreshes, updateLiveArtifact, } from './live-artifacts/store.js'; import { LiveArtifactRefreshUnavailableError, refreshLiveArtifact } from './live-artifacts/refresh-service.js'; import { LiveArtifactRefreshAbortError } from './live-artifacts/refresh.js'; import { registerConnectorRoutes } from './connectors/routes.js'; import { registerActiveContextRoutes } from './active-context-routes.js'; import { registerHostToolsRoutes } from './host-tools-routes.js'; import { registerMcpRoutes } from './mcp-routes.js'; import { registerXaiRoutes } from './xai-routes.js'; import { registerLiveArtifactRoutes } from './live-artifact-routes.js'; import { registerDesignSystemToolRoutes } from './design-system-tool-routes.js'; import { registerDeployRoutes, registerDeploymentCheckRoutes } from './deploy-routes.js'; import { registerMediaRoutes } from './media-routes.js'; import { registerProjectRoutes, registerProjectArtifactRoutes, registerProjectFileRoutes, registerProjectUploadRoutes } from './project-routes.js'; import { registerFinalizeRoutes, registerImportRoutes, registerProjectExportRoutes } from './import-export-routes.js'; import { registerHandoffRoutes } from './handoff-routes.js'; import { EmptyTranscriptError, synthesizeHandoffPrompt } from './handoff-design.js'; import { TranscriptExportLockedError } from './transcript-export.js'; import { registerChatRoutes } from './chat-routes.js'; import { registerStaticResourceRoutes } from './static-resource-routes.js'; import { registerRoutineRoutes, routineDbRowToContract } from './routine-routes.js'; import { assertServerContextSatisfiesRoutes } from './route-context-contract.js'; import { configureConnectorCredentialStore, ConnectorServiceError, FileConnectorCredentialStore } from './connectors/service.js'; import { composioConnectorProvider } from './connectors/composio.js'; import { configureComposioConfigStore } from './connectors/composio-config.js'; import { CHAT_TOOL_ENDPOINTS, CHAT_TOOL_OPERATIONS, toolTokenRegistry } from './tool-tokens.js'; import { aggregateCloudflarePagesStatus, buildDeployFileSet, checkDeploymentUrl, CLOUDFLARE_PAGES_PROVIDER_ID, cloudflarePagesProjectNameForProject, DeployError, deployToCloudflarePages, deployToVercel, isDeployProviderId, listCloudflarePagesZones, prepareDeployPreflight, publicDeployConfigForProvider, readDeployConfig, readCloudflarePagesDomain, VERCEL_PROVIDER_ID, writeDeployConfig, } from './deploy.js'; import { allowedBrowserPorts, configuredAllowedOrigins, isAllowedBrowserOrigin, isLocalSameOrigin, } from './origin-validation.js'; /** @typedef {import('@open-design/contracts').ApiErrorCode} ApiErrorCode */ /** @typedef {import('@open-design/contracts').ApiError} ApiError */ /** @typedef {import('@open-design/contracts').ApiErrorResponse} ApiErrorResponse */ /** @typedef {import('@open-design/contracts').ChatRequest} ChatRequest */ /** @typedef {import('@open-design/contracts').ChatSseEvent} ChatSseEvent */ /** @typedef {import('@open-design/contracts').ProxyStreamRequest} ProxyStreamRequest */ /** @typedef {import('@open-design/contracts').ProxySseEvent} ProxySseEvent */ /** @typedef {import('@open-design/contracts').ProjectConversationCreatedSsePayload} ProjectConversationCreatedSsePayload */ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const require = createRequire(import.meta.url); const DAEMON_CLI_PATH_ENV = 'OD_DAEMON_CLI_PATH'; export function resolveProjectRoot(moduleDir: string): string { const base = path.basename(moduleDir); const daemonDir = base === 'dist' || base === 'src' ? path.dirname(moduleDir) : moduleDir; return path.resolve(daemonDir, '../..'); } function cleanOptionalPath(value: string | undefined): string | null { return typeof value === 'string' && value.trim().length > 0 ? path.resolve(value) : null; } export function resolveDaemonCliPath(env: NodeJS.ProcessEnv = process.env): string { const configured = cleanOptionalPath(env[DAEMON_CLI_PATH_ENV]) ?? cleanOptionalPath(env.OD_BIN); if (configured) return configured; const packageJsonPath = require.resolve('@open-design/daemon/package.json'); return path.join(path.dirname(packageJsonPath), 'dist', 'cli.js'); } const PROJECT_ROOT = resolveProjectRoot(__dirname); const RESOURCE_ROOT_ENV = 'OD_RESOURCE_ROOT'; export function composeLiveInstructionPrompt({ daemonSystemPrompt, runtimeToolPrompt, clientSystemPrompt, finalPromptOverride, }) { const override = typeof finalPromptOverride === 'string' ? finalPromptOverride.trim() : ''; const parts = [daemonSystemPrompt, runtimeToolPrompt, clientSystemPrompt] .map((part) => (typeof part === 'string' ? part.trim() : '')) .map((part) => override && part.includes(override) ? part.split(override).join('').trim() : part, ) .filter(Boolean); if (override) { parts.push(override); } return parts.join('\n\n---\n\n'); } function renderPluginBriefTemplate(template, inputs = {}) { if (typeof template !== 'string' || template.length === 0) return ''; return template.replace(/\{\{\s*([a-zA-Z_][\w-]*)\s*\}\}/g, (full, key) => { if (!Object.hasOwn(inputs, key)) return full; const value = inputs[key]; if (value === undefined || value === null || value === '') return full; return String(value); }); } export function resolveResearchCommandContract(research, message) { if (!research || !research.enabled) return ''; const researchQuery = typeof research.query === 'string' && research.query.trim() ? research.query : message; return renderResearchCommandContract({ query: researchQuery, maxSources: typeof research.maxSources === 'number' ? research.maxSources : undefined, }); } export function resolveCodexGeneratedImagesDir( agentId, metadata, env = process.env, homeDir = os.homedir(), ) { if (!shouldRenderCodexImagegenOverride(agentId, metadata)) return null; const rawCodexHome = typeof env?.CODEX_HOME === 'string' && env.CODEX_HOME.trim().length > 0 ? env.CODEX_HOME.trim() : path.join(homeDir, '.codex'); const codexHome = rawCodexHome.startsWith('~/') ? path.join(homeDir, rawCodexHome.slice(2)) : rawCodexHome; return path.resolve(codexHome, 'generated_images'); } type DirectoryStat = { isDirectory(): boolean; isSymbolicLink(): boolean; }; type CodexGeneratedImagesDirValidationOptions = { protectedDirs?: Array; mkdirSync?: (target: string, options: { recursive: true }) => unknown; lstatSync?: (target: string) => DirectoryStat; statSync?: (target: string) => DirectoryStat; realpathSync?: (target: string) => string; warn?: (message: string) => void; }; function isMissingPathError(err: unknown): boolean { return ( err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT' ); } function collectProtectedDirRoots( protectedDirs: Array, { realpathSync, statSync, }: { realpathSync: (target: string) => string; statSync: (target: string) => DirectoryStat; }, ): string[] { const roots = []; for (const raw of Array.isArray(protectedDirs) ? protectedDirs : []) { if (typeof raw !== 'string' || raw.trim().length === 0) continue; const resolved = path.resolve(raw); roots.push(resolved); try { const canonical = realpathSync(resolved); try { if (statSync(canonical).isDirectory()) roots.push(canonical); } catch { roots.push(canonical); } } catch { // A missing protected root cannot be the canonical target of a symlink. } } return Array.from(new Set(roots)); } function findContainingProtectedRoot( candidate: string, protectedRoots: string[], ): string | null { return protectedRoots.find((root) => isPathWithin(root, candidate)) ?? null; } export function validateCodexGeneratedImagesDir( codexGeneratedImagesDir: string | null | undefined, { protectedDirs = [], mkdirSync = fs.mkdirSync, lstatSync = fs.lstatSync, statSync = fs.statSync, realpathSync = fs.realpathSync.native, warn = console.warn, }: CodexGeneratedImagesDirValidationOptions = {}, ): string | null { if ( typeof codexGeneratedImagesDir !== 'string' || codexGeneratedImagesDir.trim().length === 0 ) { return null; } const resolved = path.resolve(codexGeneratedImagesDir); const protectedRoots = collectProtectedDirRoots(protectedDirs, { realpathSync, statSync, }); const warnSkipped = (reason: string) => warn(`[od] codex generated_images allowlist skipped: ${reason}`); const protectedRoot = findContainingProtectedRoot(resolved, protectedRoots); if (protectedRoot) { warnSkipped(`${resolved} is inside protected root ${protectedRoot}`); return null; } try { let existingTargetStat = null; try { existingTargetStat = lstatSync(resolved); } catch (err) { if (!isMissingPathError(err)) throw err; } if (existingTargetStat?.isSymbolicLink()) { warnSkipped(`${resolved} is a symlink`); return null; } if (existingTargetStat && !existingTargetStat.isDirectory()) { warnSkipped(`${resolved} is not a directory`); return null; } const parent = path.dirname(resolved); const protectedParentRoot = findContainingProtectedRoot( parent, protectedRoots, ); if (protectedParentRoot) { warnSkipped(`${parent} is inside protected root ${protectedParentRoot}`); return null; } mkdirSync(parent, { recursive: true }); const canonicalParent = realpathSync(parent); const canonicalCandidate = path.join( canonicalParent, path.basename(resolved), ); const protectedCanonicalParentRoot = findContainingProtectedRoot( canonicalCandidate, protectedRoots, ); if (protectedCanonicalParentRoot) { warnSkipped( `${canonicalCandidate} resolves inside protected root ${protectedCanonicalParentRoot}`, ); return null; } mkdirSync(resolved, { recursive: true }); if (lstatSync(resolved).isSymbolicLink()) { warnSkipped(`${resolved} is a symlink`); return null; } if (!statSync(resolved).isDirectory()) { warnSkipped(`${resolved} is not a directory`); return null; } const canonicalDir = realpathSync(resolved); const protectedCanonicalRoot = findContainingProtectedRoot( canonicalDir, protectedRoots, ); if (protectedCanonicalRoot) { warnSkipped( `${canonicalDir} resolves inside protected root ${protectedCanonicalRoot}`, ); return null; } return canonicalDir; } catch (err) { const message = err instanceof Error ? err.message : String(err ?? 'unknown error'); warn(`[od] codex generated_images allowlist mkdir failed: ${message}`); return null; } } export function resolveChatExtraAllowedDirs({ agentId, skillsDir, designSystemsDir, linkedDirs = [], codexGeneratedImagesDir, existsSync = fs.existsSync, }: { agentId?: string | null; skillsDir?: string | null; designSystemsDir?: string | null; linkedDirs?: Array; codexGeneratedImagesDir?: string | null; existsSync?: (path: string) => boolean; }): string[] { const isCodex = typeof agentId === 'string' && agentId.trim().toLowerCase() === 'codex'; const candidates = isCodex ? [codexGeneratedImagesDir] : [ skillsDir, designSystemsDir, ...(Array.isArray(linkedDirs) ? linkedDirs : []), ]; return Array.from( new Set( candidates.filter( (d) => typeof d === 'string' && d.length > 0 && existsSync(d), ), ), ); } export function resolveGrantedCodexImagegenOverride({ agentId, metadata, codexGeneratedImagesDir, extraAllowedDirs = [], }: { agentId?: string | null; metadata?: unknown; codexGeneratedImagesDir?: string | null; extraAllowedDirs?: string[]; }): string | null { if ( typeof codexGeneratedImagesDir !== 'string' || codexGeneratedImagesDir.length === 0 || !Array.isArray(extraAllowedDirs) || !extraAllowedDirs.includes(codexGeneratedImagesDir) ) { return null; } return renderCodexImagegenOverride(agentId, metadata); } export function normalizeCommentAttachments(input) { if (!Array.isArray(input)) return []; return input .map((raw, index) => { if (!raw || typeof raw !== 'object') return null; const filePath = cleanString(raw.filePath); const elementId = cleanString(raw.elementId); const selector = cleanString(raw.selector); const label = cleanString(raw.label); const screenshotPath = cleanString(raw.screenshotPath); const markKind = normalizeVisualMarkKind(raw.markKind); const intent = compactString(raw.intent, 220); const comment = cleanString(raw.comment) || intent; const selectionKind = raw.selectionKind === 'visual' ? 'visual' : raw.selectionKind === 'pod' ? 'pod' : 'element'; if (!filePath || !elementId || !comment) return null; if (selectionKind !== 'visual' && !selector) return null; if (selectionKind === 'visual' && !screenshotPath) return null; const podMembers = selectionKind === 'pod' ? normalizeAttachmentPodMembers(raw.podMembers) : []; const memberCount = selectionKind === 'pod' ? (podMembers.length > 0 ? podMembers.length : Number.isFinite(raw.memberCount) ? Math.max(0, Math.round(raw.memberCount)) : 0) : 0; return { id: cleanString(raw.id) || `comment-${index + 1}`, order: Number.isFinite(raw.order) ? Math.max(1, Math.round(raw.order)) : index + 1, filePath, elementId, selector, label, comment, currentText: compactString(raw.currentText, 160), pagePosition: normalizeAttachmentPosition(raw.pagePosition), htmlHint: compactString(raw.htmlHint, 180), selectionKind, memberCount, podMembers, screenshotPath: selectionKind === 'visual' ? screenshotPath : undefined, markKind: selectionKind === 'visual' ? markKind : undefined, intent: selectionKind === 'visual' ? intent || visualAnnotationIntent(markKind) : undefined, source: raw.source === 'board-batch' ? 'board-batch' : 'saved-comment', }; }) .filter(Boolean) .sort((a, b) => a.order - b.order); } export function renderCommentAttachmentHint(commentAttachments) { if (!commentAttachments.length) return ''; const lines = [ '', '', '', 'Scope: treat each attachment as the default refinement target. For visual marks, inspect the screenshot and modify the marked region first. Preserve unrelated areas.', ]; for (const item of commentAttachments) { const targetKind = item.selectionKind === 'visual' ? 'visual' : item.selectionKind === 'pod' ? 'pod' : 'element'; lines.push( '', `${item.order}. ${item.elementId}`, `targetKind: ${targetKind}`, `file: ${item.filePath}`, `label: ${item.label || '(unlabeled)'}`, `position: ${formatAttachmentPosition(item.pagePosition)}`, `currentText: ${item.currentText || '(empty)'}`, `htmlHint: ${item.htmlHint || '(none)'}`, `comment: ${item.comment}`, ); if (targetKind === 'visual') { lines.push( `screenshot: ${item.screenshotPath}`, `markKind: ${item.markKind || 'stroke'}`, `intent: ${item.intent || visualAnnotationIntent(item.markKind || 'stroke')}`, ); if (item.selector) lines.push(`selector: ${item.selector}`); } else { lines.splice(lines.length - 4, 0, `selector: ${item.selector}`); } if (targetKind === 'pod') { lines.push(`memberCount: ${item.memberCount || item.podMembers.length || 0}`); item.podMembers.slice(0, 8).forEach((member, memberIndex) => { lines.push( `member.${memberIndex + 1}: ${member.elementId} | ${member.label || '(unlabeled)'} | ${member.selector}`, ); }); } } lines.push(''); return lines.join('\n'); } function cleanString(value) { return typeof value === 'string' ? value.trim() : ''; } function normalizeVisualMarkKind(value) { return value === 'click' || value === 'click+stroke' || value === 'stroke' ? value : 'stroke'; } function visualAnnotationIntent(markKind) { if (markKind === 'click') { return 'The screenshot has a blue focus box around the picked element; modify that picked part first.'; } if (markKind === 'click+stroke') { return 'The screenshot has a blue focus box and red strokes; together they identify the part the user wants changed.'; } return 'The screenshot has red strokes that identify the visual region the user wants changed.'; } function compactString(value, max) { const text = cleanString(value).replace(/\s+/g, ' '); return text.length > max ? `${text.slice(0, max - 3)}...` : text; } function normalizeAttachmentPosition(input) { const value = input && typeof input === 'object' ? input : {}; return { x: finiteAttachmentNumber(value.x), y: finiteAttachmentNumber(value.y), width: finiteAttachmentNumber(value.width), height: finiteAttachmentNumber(value.height), }; } function normalizeAttachmentPodMembers(input) { if (!Array.isArray(input)) return []; return input .map((member) => { if (!member || typeof member !== 'object') return null; const elementId = cleanString(member.elementId); const selector = cleanString(member.selector); const label = cleanString(member.label); if (!elementId || !selector) return null; return { elementId, selector, label, text: compactString(member.text, 160), position: normalizeAttachmentPosition(member.position), htmlHint: compactString(member.htmlHint, 180), }; }) .filter(Boolean); } function finiteAttachmentNumber(value) { return Number.isFinite(value) ? Math.round(value) : 0; } function formatAttachmentPosition(position) { return `x=${position.x}, y=${position.y}, width=${position.width}, height=${position.height}`; } function isPathWithin(base, target) { const relativePath = path.relative(path.resolve(base), path.resolve(target)); return ( relativePath === '' || (relativePath.length > 0 && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)) ); } export function resolveSafeProjectAttachments(cwd, attachments, opts = {}) { if (!cwd || !Array.isArray(attachments)) return []; const pathImpl = opts.pathImpl ?? path; const existsSync = opts.existsSync ?? fs.existsSync; const root = pathImpl.resolve(cwd); const out = []; for (const attachment of attachments) { if (typeof attachment !== 'string' || attachment.length === 0) continue; try { const abs = pathImpl.resolve(root, attachment); const relativePath = pathImpl.relative(root, abs); const withinRoot = relativePath === '' || (relativePath.length > 0 && !relativePath.startsWith('..') && !pathImpl.isAbsolute(relativePath)); if (withinRoot && existsSync(abs)) out.push(attachment); } catch { // Drop malformed paths; attachments are advisory prompt context. } } return out; } function resolveProcessResourcesPath() { if ( typeof process.resourcesPath === 'string' && process.resourcesPath.length > 0 ) { return process.resourcesPath; } // Packaged daemon sidecars run under the bundled Node binary rather than the // Electron root process, so `process.resourcesPath` is unavailable there. // Infer the macOS app Resources directory from that bundled Node path. const resourcesMarker = `${path.sep}Contents${path.sep}Resources${path.sep}`; const markerIndex = process.execPath.indexOf(resourcesMarker); if (markerIndex !== -1) { return process.execPath.slice(0, markerIndex + resourcesMarker.length - 1); } const normalizedExecPath = process.execPath.toLowerCase(); const windowsResourceBinMarker = `${path.sep}resources${path.sep}open-design${path.sep}bin${path.sep}`.toLowerCase(); const windowsMarkerIndex = normalizedExecPath.indexOf( windowsResourceBinMarker, ); if (windowsMarkerIndex !== -1) { return process.execPath.slice( 0, windowsMarkerIndex + `${path.sep}resources`.length, ); } return null; } export function resolveDaemonResourceRoot({ configured = process.env[RESOURCE_ROOT_ENV], safeBases = [PROJECT_ROOT, resolveProcessResourcesPath()], } = {}) { if (!configured || configured.length === 0) return null; const resolved = path.resolve(configured); const normalizedSafeBases = safeBases .filter((base) => typeof base === 'string' && base.length > 0) .map((base) => path.resolve(base)); if (!normalizedSafeBases.some((base) => isPathWithin(base, resolved))) { throw new Error( `${RESOURCE_ROOT_ENV} must be under the workspace root or app resources path`, ); } return resolved; } function resolveDaemonResourceDir(resourceRoot, segment, fallback) { return resourceRoot ? path.join(resourceRoot, segment) : fallback; } const DAEMON_RESOURCE_ROOT = resolveDaemonResourceRoot(); // Built web app lives in `out/` — that's where Next.js writes the static // export configured in next.config.ts. The folder name used to be `dist/` // when this project shipped with Vite; the daemon serves whatever the // frontend toolchain emits, no further config needed. const STATIC_DIR = path.join(PROJECT_ROOT, 'apps', 'web', 'out'); const OD_BIN = resolveDaemonCliPath(); const OD_NODE_BIN = process.execPath; const SKILLS_DIR = resolveDaemonResourceDir( DAEMON_RESOURCE_ROOT, 'skills', path.join(PROJECT_ROOT, 'skills'), ); const DESIGN_SYSTEMS_DIR = resolveDaemonResourceDir( DAEMON_RESOURCE_ROOT, 'design-systems', path.join(PROJECT_ROOT, 'design-systems'), ); // Renderable templates pulled out of `skills/` by the skills/design-templates // split (PR #955) so the EntryView Templates tab gets the large rendering // catalogue and Settings → Skills only carries functional skills the agent // invokes mid-task. See specs/current/skills-and-design-templates.md. const DESIGN_TEMPLATES_DIR = resolveDaemonResourceDir( DAEMON_RESOURCE_ROOT, 'design-templates', path.join(PROJECT_ROOT, 'design-templates'), ); const CRAFT_DIR = resolveDaemonResourceDir( DAEMON_RESOURCE_ROOT, 'craft', path.join(PROJECT_ROOT, 'craft'), ); // User-installed skills and design systems live under the runtime data dir // so they respect OD_DATA_DIR overrides (test isolation, packaged runs). // Defined after RUNTIME_DATA_DIR is resolved below. const FRAMES_DIR = resolveDaemonResourceDir( DAEMON_RESOURCE_ROOT, 'frames', path.join(PROJECT_ROOT, 'assets', 'frames'), ); // Curated pets baked into the repo via `scripts/bake-community-pets.ts`. // `listCodexPets` scans this in addition to `~/.codex/pets/` so the // "Recently hatched" grid is non-empty out-of-the-box and users do not // need to hit the "Download community pets" button to try a few pets. const BUNDLED_PETS_DIR = resolveDaemonResourceDir( DAEMON_RESOURCE_ROOT, 'community-pets', path.join(PROJECT_ROOT, 'assets', 'community-pets'), ); const PROMPT_TEMPLATES_DIR = resolveDaemonResourceDir( DAEMON_RESOURCE_ROOT, 'prompt-templates', path.join(PROJECT_ROOT, 'prompt-templates'), ); const BUNDLED_PLUGINS_DIR = resolveDaemonResourceDir( DAEMON_RESOURCE_ROOT, path.join('plugins', '_official'), defaultBundledRoot(PROJECT_ROOT), ); const PLUGIN_REGISTRY_DIR = resolveDaemonResourceDir( DAEMON_RESOURCE_ROOT, 'plugins/registry', path.join(PROJECT_ROOT, 'plugins', 'registry'), ); const OFFICIAL_MARKETPLACE_ID = 'official'; const OFFICIAL_PLUGIN_SOURCE_REPO = 'github:nexu-io/open-design@main'; export function isStaticSpaFallbackRequest(req) { if (req.method !== 'GET' && req.method !== 'HEAD') return false; if (req.path === '/api' || req.path.startsWith('/api/')) return false; if (req.path === '/artifacts' || req.path.startsWith('/artifacts/')) return false; if (req.path === '/frames' || req.path.startsWith('/frames/')) return false; if (req.path === '/_next' || req.path.startsWith('/_next/')) return false; const accept = req.get?.('accept') ?? ''; return accept.length === 0 || accept.includes('text/html') || accept.includes('*/*'); } export function resolveStaticSpaFallbackPath(req, staticDir) { const indexPath = path.join(staticDir, 'index.html'); if (!fs.existsSync(indexPath) || !isStaticSpaFallbackRequest(req)) return null; return indexPath; } export function registerStaticSpaFallback(app, staticDir) { app.get('*', (req, res, next) => { const indexPath = resolveStaticSpaFallbackPath(req, staticDir); if (indexPath == null) return next(); res.sendFile(indexPath); }); } function defaultMarketplaceSeedConfig(id) { return { trust: id === OFFICIAL_MARKETPLACE_ID ? 'official' : 'restricted', url: marketplaceManifestUrlForRegistry(id), }; } function bundledPluginRegistrySource(sourcePath) { if (isPathWithin(BUNDLED_PLUGINS_DIR, sourcePath)) { const rel = path.relative(BUNDLED_PLUGINS_DIR, sourcePath).split(path.sep).join('/'); return `${OFFICIAL_PLUGIN_SOURCE_REPO}/plugins/_official/${rel}`; } const rel = path.relative(PROJECT_ROOT, sourcePath).split(path.sep).join('/'); if (!rel || rel.startsWith('..')) return sourcePath; return `${OFFICIAL_PLUGIN_SOURCE_REPO}/${rel}`; } function mergeMarketplaceEntries(manifestText, entries) { try { const parsed = JSON.parse(manifestText); const plugins = Array.isArray(parsed.plugins) ? parsed.plugins : []; const seen = new Set(plugins.map((entry) => String(entry?.name ?? '').toLowerCase())); const generated = entries.filter((entry) => { const key = String(entry.name ?? '').toLowerCase(); if (!key || seen.has(key)) return false; seen.add(key); return true; }); return JSON.stringify({ ...parsed, metadata: { ...(parsed.metadata && typeof parsed.metadata === 'object' ? parsed.metadata : {}), bundledPreinstallCount: entries.length, }, plugins: [...plugins, ...generated], }); } catch { return manifestText; } } async function marketplaceSeedManifestText(id, bundledMarketplaceEntries) { const manifestPath = path.join(PLUGIN_REGISTRY_DIR, id, 'open-design-marketplace.json'); if (!fs.existsSync(manifestPath)) return null; let manifestText = await fs.promises.readFile(manifestPath, 'utf8'); if (id === OFFICIAL_MARKETPLACE_ID && bundledMarketplaceEntries.length > 0) { manifestText = mergeMarketplaceEntries(manifestText, bundledMarketplaceEntries); } return manifestText; } function createMarketplaceFetcher(seedId, bundledMarketplaceEntries) { return async (url) => { const registryId = marketplaceRegistryIdFromUrl(url); if (registryId && (!seedId || registryId === seedId)) { const manifestText = await marketplaceSeedManifestText(registryId, bundledMarketplaceEntries); if (manifestText != null) { return { ok: true, status: 200, text: async () => manifestText, }; } } const response = await fetch(url, { redirect: 'follow' }); return { ok: response.ok, status: response.status, text: () => response.text(), }; }; } export function resolveDataDir(raw, projectRoot) { if (!raw) return path.join(projectRoot, '.od'); // expandHomePrefix is shared with media-config.ts so OD_DATA_DIR and // OD_MEDIA_CONFIG_DIR can never split state under a $HOME-style value. // Some launchers (systemd unit files, NixOS modules, certain Docker // entrypoints, Windows scheduled tasks) pass OD_DATA_DIR with literal // $HOME or ${HOME} because the variable is never expanded by a shell; // expandHomePrefix turns those (and the ~ shorthand, with both / and \ // separators) into os.homedir() before path.resolve runs so launch // surfaces stay consistent. const resolved = resolveProjectRelativePath(raw, projectRoot); try { fs.mkdirSync(resolved, { recursive: true }); fs.accessSync(resolved, fs.constants.W_OK); } catch (err) { const e = err; const currentUser = (() => { try { return os.userInfo().username; } catch { return process.env.USER ?? process.env.LOGNAME ?? 'unknown'; } })(); const parentDir = path.dirname(resolved); throw new Error( [ `OD_DATA_DIR "${resolved}" is not writable: ${e.message}`, `Current user: ${currentUser}`, `Check whether the folder or one of its parents is owned by another user, is a symlink to a protected location, or was previously created with sudo.`, `Try: ls -ld "${parentDir}" "${resolved}"`, `If the folder should belong to you, fix ownership/permissions, for example: sudo chown -R "${currentUser}":staff "${parentDir}" && chmod -R u+rwX "${parentDir}"`, ].join(' '), ); } return resolved; } const RUNTIME_DATA_DIR = resolveDataDir(process.env.OD_DATA_DIR, PROJECT_ROOT); const PLUGIN_LOCKFILE_PATH = path.join(RUNTIME_DATA_DIR, 'od-plugin-lock.json'); // Canonical (realpath-resolved) form of RUNTIME_DATA_DIR for the few callers // that compare it against a user-supplied realpath() result. On macOS, /var // is a symlink to /private/var, so an import realpath lands in /private/var // and would never start-with the raw RUNTIME_DATA_DIR. Keep RUNTIME_DATA_DIR // itself as the stable, user-shaped path so OD_DATA_DIR resolution stays // predictable; only this canonical alias is used for symlink-aware checks. const RUNTIME_DATA_DIR_CANONICAL = (() => { try { return fs.realpathSync(RUNTIME_DATA_DIR); } catch { return RUNTIME_DATA_DIR; } })(); // One-shot legacy data migration. When OD_LEGACY_DATA_DIR is set and the // new data root is fresh (no app.sqlite), copy the 0.3.x .od/ payload // across before SQLite opens. Synchronous on purpose: openDatabase below // would race an async copy. See apps/daemon/src/legacy-data-migrator.ts // and https://github.com/nexu-io/open-design/issues/710. migrateLegacyDataDirSync({ legacyDir: process.env.OD_LEGACY_DATA_DIR, dataDir: RUNTIME_DATA_DIR, }); const ARTIFACTS_DIR = path.join(RUNTIME_DATA_DIR, 'artifacts'); // Critique Theater artifacts intentionally live outside the static // `/artifacts` tree. The per-run artifact endpoint is the sanctioned // read path so project-membership, size, and CSP guards cannot be bypassed. const CRITIQUE_ARTIFACTS_DIR = path.join(RUNTIME_DATA_DIR, 'critique-artifacts'); const PROJECTS_DIR = path.join(RUNTIME_DATA_DIR, 'projects'); const USER_SKILLS_DIR = path.join(RUNTIME_DATA_DIR, 'skills'); const USER_DESIGN_SYSTEMS_DIR = path.join(RUNTIME_DATA_DIR, 'design-systems'); const PLUGIN_REGISTRY_ROOTS = registryRootsForDataDir(RUNTIME_DATA_DIR); // User-imported design templates mirror USER_SKILLS_DIR but are scanned // against DESIGN_TEMPLATES_DIR rather than SKILLS_DIR so the EntryView // Templates surface and the Settings → Skills surface stay decoupled. const USER_DESIGN_TEMPLATES_DIR = path.join(RUNTIME_DATA_DIR, 'design-templates'); // Multi-root tuples used everywhere the daemon resolves a skill / template // id without knowing which surface it came from. SKILL_ROOTS drives // Settings → Skills; DESIGN_TEMPLATE_ROOTS drives the EntryView Templates // gallery; ALL_SKILL_LIKE_ROOTS spans both for chat run system-prompt // composition and the orbit template resolver, where stored project ids // can resolve to either root after the split. const SKILL_ROOTS = [USER_SKILLS_DIR, SKILLS_DIR]; const DESIGN_TEMPLATE_ROOTS = [USER_DESIGN_TEMPLATES_DIR, DESIGN_TEMPLATES_DIR]; const ALL_SKILL_LIKE_ROOTS = [ USER_SKILLS_DIR, USER_DESIGN_TEMPLATES_DIR, SKILLS_DIR, DESIGN_TEMPLATES_DIR, ]; fs.mkdirSync(PROJECTS_DIR, { recursive: true }); for (const dir of [USER_SKILLS_DIR, USER_DESIGN_SYSTEMS_DIR, USER_DESIGN_TEMPLATES_DIR, PLUGIN_REGISTRY_ROOTS.userPluginsRoot]) { fs.mkdirSync(dir, { recursive: true }); } fs.mkdirSync(CRITIQUE_ARTIFACTS_DIR, { recursive: true }); const orbitService = new OrbitService(RUNTIME_DATA_DIR); const designSystemGenerationJobs = createDesignSystemGenerationJobStore({ root: USER_DESIGN_SYSTEMS_DIR, }); let routineService = null; // In-memory OAuth state cache. Lives for the daemon process's lifetime. // Maps the OAuth `state` parameter we generated in /api/mcp/oauth/start // to the verifier + endpoint info needed to finish the exchange when the // browser hits /api/mcp/oauth/callback. const mcpPendingAuth = new PendingAuthCache(); /** * Resolve the daemon's public base URL — the origin the user's browser * (or the OAuth provider) reaches us at. Order of precedence: * * 1. `OD_PUBLIC_BASE_URL` env var. Cloud and packaged-electron deployments * set this to the externally-routable URL (e.g. `https://app.example.com`). * 2. `req.protocol://req.get('host')` from the inbound request. Works in * local dev and most reverse-proxy setups (Express respects * `trust proxy` so X-Forwarded-* headers are honored). * * The OAuth callback URI is derived from this — it MUST be reachable from * the user's browser, otherwise the redirect after auth lands on * ERR_CONNECTION_REFUSED. Misconfiguration is loud: the OAuth provider * will reject `redirect_uri` mismatches. */ function getPublicBaseUrl(req) { const env = process.env.OD_PUBLIC_BASE_URL; if (env && /^https?:\/\//i.test(env)) { return env.replace(/\/+$/u, ''); } const proto = req.protocol || 'http'; const host = req.get('host'); if (!host) return `http://localhost:${process.env.OD_PORT ?? '7456'}`; return `${proto}://${host}`; } function mcpOAuthCallbackUrl(req) { return `${getPublicBaseUrl(req)}/api/mcp/oauth/callback`; } /** * Refresh an expired token using the OAuth client context that the original * authorization-code exchange persisted alongside the token. Refresh tokens * are bound (RFC 6749 §6) to the client that received them, so we MUST * refresh against the same `tokenEndpoint` / `clientId` / `clientSecret` * pair — re-running discovery with a different redirect URI would risk * registering a new client_id that the upstream then rejects the refresh * for. Tokens persisted before that context was recorded can't be safely * refreshed; the caller treats `null` as "needs reconnect". */ async function refreshAndPersistToken(dataDir, serverId, current) { if (!current.refreshToken) return null; if (!current.tokenEndpoint || !current.clientId) return null; const tokenResp = await refreshAccessToken({ tokenEndpoint: current.tokenEndpoint, clientId: current.clientId, clientSecret: current.clientSecret, refreshToken: current.refreshToken, scope: current.scope, resource: current.resourceUrl, }); const next = { accessToken: tokenResp.access_token, refreshToken: tokenResp.refresh_token ?? current.refreshToken, tokenType: tokenResp.token_type ?? 'Bearer', scope: tokenResp.scope ?? current.scope, expiresAt: typeof tokenResp.expires_in === 'number' ? Date.now() + tokenResp.expires_in * 1000 : undefined, savedAt: Date.now(), tokenEndpoint: current.tokenEndpoint, clientId: current.clientId, clientSecret: current.clientSecret, authServerIssuer: current.authServerIssuer, redirectUri: current.redirectUri, resourceUrl: current.resourceUrl, }; await setToken(dataDir, serverId, next); return next; } const activeChatAgentEventSinks = new Map(); const activeProjectEventSinks = new Map(); function emitChatAgentEvent(runId, payload) { const sink = activeChatAgentEventSinks.get(runId); if (!sink) return false; return sink(payload); } function emitLiveArtifactEvent(grant, action, artifact) { if (!artifact?.id) return false; const payload = { type: 'live_artifact', action, projectId: artifact.projectId ?? grant.projectId, artifactId: artifact.id, title: artifact.title ?? artifact.id, refreshStatus: artifact.refreshStatus, }; let emitted = emitProjectEvent(payload.projectId, payload); if (grant?.runId) emitted = emitChatAgentEvent(grant.runId, payload) || emitted; return emitted; } function emitLiveArtifactRefreshEvent(grant, payload) { if (!payload?.artifactId) return false; const event = { type: 'live_artifact_refresh', projectId: grant.projectId, ...payload, }; let emitted = emitProjectEvent(grant.projectId, event); if (grant?.runId) emitted = emitChatAgentEvent(grant.runId, event) || emitted; return emitted; } // Broadcast an event to every SSE subscriber currently watching the given // project's `/api/projects/:id/events` stream. The payload's `type` field // becomes the SSE event name (see project-routes.ts). Used for live-artifact // events and `conversation-created` events emitted by routine runs (#1361). function emitProjectEvent(projectId, payload) { const sinks = activeProjectEventSinks.get(projectId); if (!sinks || sinks.size === 0) return false; for (const sink of Array.from(sinks)) { try { sink(payload); } catch { sinks.delete(sink); } } if (sinks.size === 0) activeProjectEventSinks.delete(projectId); return true; } // Windows ENAMETOOLONG mitigation constants const CMD_BAT_RE = /\.(cmd|bat)$/i; const PROMPT_TEMP_FILE = () => '.od-prompt-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8) + '.md'; const promptFileBootstrap = (fp) => `Your full instructions are stored in the file: ${fp.replace(/\\/g, '/')}. ` + 'Open that file first and follow every instruction in it exactly — ' + 'it contains the system prompt, design system, skill workflow, and user request. ' + 'Do not begin your response until you have read the entire file.'; // Load Critique Theater config once at startup so a bad OD_CRITIQUE_* value // surfaces immediately as a boot-time RangeError instead of silently at // run time. Default: enabled=false (M0 dark launch). const critiqueCfg = loadCritiqueConfigFromEnv(); // Tracks adapter streamFormat values that have already received a one-time // warning explaining why the Critique Theater orchestrator was bypassed. // Adapter denylist for orchestrator routing is implicit: anything that is // not the 'plain' streamFormat falls through to legacy single-pass. const critiqueWarnedAdapters = new Set(); // In-process registry of in-flight critique runs so the interrupt endpoint // can cascade an AbortController to the matching orchestrator invocation. // Created once per process; not persisted across daemon restarts. const critiqueRunRegistry = createRunRegistry(); export const SSE_KEEPALIVE_INTERVAL_MS = 25_000; export function createAgentRuntimeEnv( baseEnv: NodeJS.ProcessEnv | Record, daemonUrl: string, toolTokenGrant: { token?: string } | null = null, nodeBin: string = process.execPath, ): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...baseEnv, OD_DATA_DIR: RUNTIME_DATA_DIR, OD_DAEMON_URL: daemonUrl, OD_NODE_BIN: nodeBin, }; // Ensure the node binary directory is on PATH so agent sub-processes — // in particular npm .cmd shims on Windows that run `"node" script.js` — // can find the same node binary that runs the daemon even when the daemon // was launched with a full path to node and the directory was not on PATH. const nodeBinDir = path.dirname(nodeBin); if (nodeBinDir) { // On Windows, process.env spreads with the search path under 'Path' rather // than 'PATH'. Locate the key case-insensitively so we read and write the // same entry that child_process.spawn consults. If we blindly write a new // 'PATH' key alongside an existing 'Path', Node's case-insensitive env // de-duplication on Windows lets the new key win — dropping all inherited // directories (git, npm, agent shims, etc.) from the child's search path. const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') ?? 'PATH'; const existingPath = typeof env[pathKey] === 'string' ? (env[pathKey] as string) : ''; const parts = existingPath.split(path.delimiter).filter((p) => p.length > 0); const normalize = (p: string) => p.replace(/[/\\]+$/, ''); const normalizedDir = normalize(nodeBinDir); const alreadyIncluded = parts.some((p) => { const n = normalize(p); return process.platform === 'win32' ? n.toLowerCase() === normalizedDir.toLowerCase() : n === normalizedDir; }); if (!alreadyIncluded) { env[pathKey] = [nodeBinDir, ...parts].join(path.delimiter); } } if (toolTokenGrant?.token) { env.OD_TOOL_TOKEN = toolTokenGrant.token; } else { delete env.OD_TOOL_TOKEN; } return env; } export function createAgentRuntimeToolPrompt( daemonUrl: string, toolTokenGrant: { token?: string } | null = null, ): string { const tokenLine = toolTokenGrant?.token ? '- `OD_TOOL_TOKEN` is available in your environment for this run. Use it only through project wrapper commands; do not print, persist, or override it.' : '- `OD_TOOL_TOKEN` is not available for this run, so `/api/tools/*` wrapper commands may be unavailable.'; return [ '## Runtime tool environment', '', `- Daemon URL: \`${daemonUrl}\` (also available as \`OD_DAEMON_URL\`).`, '- `OD_NODE_BIN` is the absolute path to the Node-compatible runtime that started the daemon; packaged desktop installs provide this even when the user has no system `node` on PATH.', '- `OD_BIN` is the absolute path to the Open Design CLI script. On POSIX shells run wrappers with `"$OD_NODE_BIN" "$OD_BIN" tools ...`; do not call bare `od`, which may resolve to the system octal-dump command on Unix-like systems.', '- On PowerShell use `& $env:OD_NODE_BIN $env:OD_BIN tools ...`; on cmd.exe use `"%OD_NODE_BIN%" "%OD_BIN%" tools ...`.', tokenLine, '- Prefer project wrapper commands through `OD_NODE_BIN` + `OD_BIN` over raw HTTP. The wrappers read these environment values automatically.', ].join('\n'); } function normalizeRunContextSelection(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; const stringList = (items) => { if (!Array.isArray(items)) return []; const out = []; const seen = new Set(); for (const item of items) { if (typeof item !== 'string') continue; const trimmed = item.trim(); if (!trimmed || seen.has(trimmed)) continue; seen.add(trimmed); out.push(trimmed); } return out; }; return { skillIds: stringList(value.skillIds), pluginIds: stringList(value.pluginIds), mcpServerIds: stringList(value.mcpServerIds), connectorIds: stringList(value.connectorIds), }; } function mergeRunContextSelections(...contexts) { const merged = { skillIds: [], pluginIds: [], mcpServerIds: [], connectorIds: [] }; for (const context of contexts) { const normalized = normalizeRunContextSelection(context); for (const key of Object.keys(merged)) { const seen = new Set(merged[key]); for (const id of normalized[key] ?? []) { if (!seen.has(id)) { seen.add(id); merged[key].push(id); } } } } return Object.fromEntries( Object.entries(merged).filter(([, ids]) => ids.length > 0), ); } function projectMetadataContextSelection(metadata) { if (!metadata || typeof metadata !== 'object') return {}; return { pluginIds: Array.isArray(metadata.contextPlugins) ? metadata.contextPlugins.map((item) => item?.id).filter((id) => typeof id === 'string') : [], mcpServerIds: Array.isArray(metadata.contextMcpServers) ? metadata.contextMcpServers.map((item) => item?.id).filter((id) => typeof id === 'string') : [], connectorIds: Array.isArray(metadata.contextConnectors) ? metadata.contextConnectors.map((item) => item?.id).filter((id) => typeof id === 'string') : [], }; } function formatContextRefList(ids, refs, titleKey = 'title') { const byId = new Map(); if (Array.isArray(refs)) { for (const ref of refs) { if (ref && typeof ref.id === 'string') byId.set(ref.id, ref); } } return ids .map((id) => { const ref = byId.get(id); const label = typeof ref?.[titleKey] === 'string' && ref[titleKey].trim() ? ref[titleKey].trim() : typeof ref?.label === 'string' && ref.label.trim() ? ref.label.trim() : typeof ref?.name === 'string' && ref.name.trim() ? ref.name.trim() : id; const meta = [ ref?.provider, ref?.transport, ref?.status, ref?.accountLabel, ].filter((value) => typeof value === 'string' && value.trim()).join(' · '); return `- ${label} (\`${id}\`)${meta ? ` — ${meta}` : ''}`; }) .join('\n'); } function renderRunContextPrompt(selection, metadata) { const context = mergeRunContextSelections(projectMetadataContextSelection(metadata), selection); const lines = []; if (Array.isArray(context.pluginIds) && context.pluginIds.length > 0) { lines.push('### Selected plugins'); lines.push( 'The user selected these plugins as run context. When an active plugin snapshot is pinned, follow that executable plugin block; otherwise combine these plugins as requested references.', ); lines.push(formatContextRefList(context.pluginIds, metadata?.contextPlugins ?? [], 'title')); } if (Array.isArray(context.mcpServerIds) && context.mcpServerIds.length > 0) { lines.push('### Selected MCP servers'); lines.push( 'The user selected these MCP servers for this run. Prefer their tools when they are mounted and relevant before asking where data should come from.', ); lines.push(formatContextRefList(context.mcpServerIds, metadata?.contextMcpServers ?? [], 'label')); } if (Array.isArray(context.connectorIds) && context.connectorIds.length > 0) { lines.push('### Selected connectors'); lines.push( 'The user selected these connectors for this run. Discover available read-only connector tools first with `"$OD_NODE_BIN" "$OD_BIN" tools connectors list --format compact`, then execute relevant tools through `tools connectors execute`; do not ask for a data source that is already selected.', ); lines.push(formatContextRefList(context.connectorIds, metadata?.contextConnectors ?? [], 'name')); } if (lines.length === 0) return ''; return ['## Selected run context', ...lines].join('\n'); } export function normalizeProjectDisplayStatus(status) { return status === 'starting' || status === 'queued' ? 'running' : status; } export function composeProjectDisplayStatus( baseStatus, awaitingInputProjects, projectId, ) { if ( baseStatus.value === 'succeeded' && awaitingInputProjects.has(projectId) ) { return { ...baseStatus, value: 'awaiting_input' }; } return { ...baseStatus, value: normalizeProjectDisplayStatus(baseStatus.value), }; } /** * @param {ApiErrorCode} code * @param {string} message * @param {Omit} [init] * @returns {ApiError} */ export function createCompatApiError(code, message, init = {}) { return { code, message, ...init }; } /** * @param {ApiErrorCode} code * @param {string} message * @param {Omit} [init] * @returns {ApiErrorResponse} */ export function createCompatApiErrorResponse(code, message, init = {}) { return { error: createCompatApiError(code, message, init) }; } /** * @param {import('express').Response} res * @param {number} status * @param {ApiErrorCode} code * @param {string} message * @param {Omit} [init] */ function sendApiError(res, status, code, message, init = {}) { return res .status(status) .json(createCompatApiErrorResponse(code, message, init)); } function normalizeProjectPluginFolderPath(input) { const value = String(input ?? '').replace(/\\/g, '/').trim(); if (!value || value.includes('\0') || value.startsWith('/') || /^[A-Za-z]:\//.test(value)) { throw new Error('plugin folder path must be a relative project path'); } const parts = value.split('/').filter(Boolean); if (parts.length === 0 || parts.some((part) => part === '.' || part === '..')) { throw new Error('plugin folder path must not contain traversal segments'); } return parts.join('/'); } async function resolveProjectChildDirectory(projectRoot, relativePath) { const rootReal = await fs.promises.realpath(projectRoot); const candidate = path.resolve(projectRoot, relativePath); const real = await fs.promises.realpath(candidate); if (!real.startsWith(rootReal + path.sep) && real !== rootReal) { throw new Error('plugin folder path escapes project dir'); } const st = await fs.promises.stat(real); if (!st.isDirectory()) { const err = new Error('plugin folder path is not a directory'); err.code = 'ENOTDIR'; throw err; } return real; } function execFileBuffered(command, args, opts = {}) { return new Promise((resolve) => { execFile(command, args, { timeout: 120_000, maxBuffer: 1024 * 1024, ...opts }, (error, stdout, stderr) => { resolve({ ok: !error, code: error?.code, stdout: String(stdout ?? '').trim(), stderr: String(stderr ?? '').trim(), error, }); }); }); } async function readProjectPluginManifest(folder) { const raw = await fs.promises.readFile(path.join(folder, 'open-design.json'), 'utf8'); const manifest = JSON.parse(raw); const name = typeof manifest.name === 'string' && manifest.name.trim() ? manifest.name.trim() : path.basename(folder); return { name, title: typeof manifest.title === 'string' ? manifest.title : name, version: typeof manifest.version === 'string' ? manifest.version : '0.1.0', manifest, }; } function githubRepoNameFromPluginName(name) { const slug = String(name) .toLowerCase() .replace(/[^a-z0-9._-]+/g, '-') .replace(/(^[-._]+|[-._]+$)/g, ''); return slug || 'open-design-plugin'; } const PLUGIN_SHARE_ACTION_LABELS = { 'publish-github': 'Publish to GitHub', 'contribute-open-design': 'Contribute to Open Design', }; const USER_PLUGIN_SOURCE_KINDS = new Set([ 'user', 'project', 'marketplace', 'github', 'url', 'local', ]); const PLUGIN_CONTEXT_SKIP_DIRS = new Set([ '.git', '.next', '.nuxt', '.od', '.output', '.tmp', '.turbo', '.venv', '__pycache__', 'build', 'coverage', 'dist', 'node_modules', 'out', 'target', 'vendor', ]); const PLUGIN_CONTEXT_SKIP_FILES = new Set([ '.DS_Store', 'Thumbs.db', ]); function normalizePluginShareAction(input) { const value = typeof input === 'string' ? input.trim() : ''; return Object.prototype.hasOwnProperty.call(PLUGIN_SHARE_ACTION_PLUGIN_IDS, value) ? value : null; } function renderPluginSharePrompt({ action, sourcePlugin, stagedPath }) { const title = sourcePlugin.title || sourcePlugin.id; if (action === 'publish-github') { return [ `Publish the local Open Design plugin "${title}" as a new public GitHub repository.`, '', `The plugin source files have been copied into this project at \`${stagedPath}\`.`, 'Use the local daemon share endpoint so the publish flow runs through Open Design\'s validated GitHub path:', '', '```bash', `curl -sS -X POST "$OD_DAEMON_URL/api/projects/$OD_PROJECT_ID/plugins/publish-github" \\`, ` -H 'content-type: application/json' \\`, ` -d '${JSON.stringify({ path: stagedPath })}'`, '```', '', 'Read the JSON response. If `ok` is true, report the final repository URL and any validation/log summary. If it fails, report the `message`, `code`, and the useful log lines. The endpoint checks `gh` auth and performs the repository creation; do not hand-roll a second GitHub flow unless you are explaining a daemon endpoint failure.', '', 'Do not rewrite the plugin unless publishing requires a small metadata fix. If you make any fix, explain it before publishing.', ].join('\n'); } return [ `Open a pull request to add the local Open Design plugin "${title}" to the Open Design repository.`, '', `The plugin source files have been copied into this project at \`${stagedPath}\`.`, 'Use the local daemon share endpoint so the contribution flow runs through Open Design\'s validated GitHub path:', '', '```bash', `curl -sS -X POST "$OD_DAEMON_URL/api/projects/$OD_PROJECT_ID/plugins/contribute-open-design" \\`, ` -H 'content-type: application/json' \\`, ` -d '${JSON.stringify({ path: stagedPath })}'`, '```', '', 'Read the JSON response. If `ok` is true, report the PR URL, branch, and any validation/log summary. If it fails, report the `message`, `code`, and the useful log lines. The endpoint checks `gh` auth, forks/clones, pushes, and opens the PR; do not hand-roll a second GitHub flow unless you are explaining a daemon endpoint failure.', '', 'Keep the PR focused on this plugin. Report the PR URL and any validation you ran.', ].join('\n'); } async function copyPluginFolderForProjectContext(sourceRoot, destRoot) { const rootReal = await fs.promises.realpath(sourceRoot); const stat = await fs.promises.stat(rootReal); if (!stat.isDirectory()) { const err = new Error('plugin source path is not a directory'); err.code = 'ENOTDIR'; throw err; } await copyPluginContextDir(rootReal, destRoot, rootReal); } async function copyPluginContextDir(src, dest, rootReal) { await fs.promises.mkdir(dest, { recursive: true }); const entries = await fs.promises.readdir(src, { withFileTypes: true }); for (const entry of entries) { if (shouldSkipPluginContextEntry(entry.name)) continue; if (entry.isSymbolicLink()) continue; const from = path.join(src, entry.name); const to = path.join(dest, entry.name); if (entry.isDirectory()) { const childReal = await fs.promises.realpath(from).catch(() => null); if (!childReal || (childReal !== rootReal && !childReal.startsWith(rootReal + path.sep))) { continue; } await copyPluginContextDir(childReal, to, rootReal); continue; } if (!entry.isFile()) continue; await fs.promises.mkdir(path.dirname(to), { recursive: true }); await fs.promises.copyFile(from, to); } } function shouldSkipPluginContextEntry(name) { return PLUGIN_CONTEXT_SKIP_DIRS.has(name) || PLUGIN_CONTEXT_SKIP_FILES.has(name); } async function ensureGhReady() { const version = await execFileBuffered('gh', ['--version'], { timeout: 10_000 }); if (!version.ok) { return { ok: false, code: 'gh-not-installed', message: 'GitHub CLI is not installed. Install it, then click this action again.', url: 'https://cli.github.com/', log: [version.stderr || version.stdout || 'gh --version failed'], }; } const auth = await execFileBuffered('gh', ['auth', 'status', '--hostname', 'github.com'], { timeout: 10_000 }); if (!auth.ok) { return { ok: false, code: 'gh-not-authenticated', message: 'GitHub CLI is installed but not authenticated. Run `gh auth login --web`, finish browser authorization, then click this action again.', url: 'https://github.com/login/device', log: [auth.stderr || auth.stdout || 'gh auth status failed'], }; } return { ok: true, log: [version.stdout, auth.stderr || auth.stdout].filter(Boolean) }; } const TERMINAL_RUN_STATUSES = new Set(['succeeded', 'failed', 'canceled']); function reconcileAssistantMessageOnRunEnd(db, runs, run) { if (!run.assistantMessageId) return; void runs .wait(run) .then((finalStatus) => { db.prepare( `UPDATE messages SET run_status = ?, ended_at = COALESCE(ended_at, ?) WHERE id = ? AND run_status IN ('queued', 'running')`, ).run(finalStatus.status, Date.now(), run.assistantMessageId); }) .catch((err) => { console.warn('[runs] message reconciliation failed', err); }); } function persistRunEventToAssistantMessage(db, run, event, data) { if (!run.assistantMessageId) return; const persisted = runSseEventToPersistedAgentEvent(event, data); if (!persisted) return; try { appendMessageAgentEvent(db, run.assistantMessageId, persisted); } catch (err) { console.warn('[runs] message event persistence failed', err); } } function runSseEventToPersistedAgentEvent(event, data) { if (event === 'start') { return { kind: 'status', label: 'starting', ...(typeof data?.bin === 'string' ? { detail: data.bin } : {}), }; } if (event === 'stdout') { const chunk = typeof data?.chunk === 'string' ? data.chunk : ''; return chunk ? { kind: 'text', text: chunk } : null; } if (event === 'error') { const message = typeof data?.error?.message === 'string' ? data.error.message : typeof data?.message === 'string' ? data.message : ''; return { kind: 'status', label: 'error', ...(message ? { detail: message } : {}), }; } if (event !== 'agent') return null; return daemonAgentPayloadToPersistedAgentEvent(data); } function daemonAgentPayloadToPersistedAgentEvent(data) { const type = data?.type; if (type === 'status' && typeof data.label === 'string') { const detail = typeof data.detail === 'string' ? data.detail : typeof data.model === 'string' ? data.model : typeof data.ttftMs === 'number' ? `first token in ${Math.round(data.ttftMs / 100) / 10}s` : undefined; return { kind: 'status', label: data.label, ...(detail ? { detail } : {}) }; } if (type === 'text_delta' && typeof data.delta === 'string') { return { kind: 'text', text: data.delta }; } if (type === 'thinking_delta' && typeof data.delta === 'string') { return { kind: 'thinking', text: data.delta }; } if (type === 'thinking_start') return { kind: 'status', label: 'thinking' }; if (type === 'live_artifact') { return { kind: 'live_artifact', action: data.action, projectId: data.projectId, artifactId: data.artifactId, title: data.title, ...(data.refreshStatus ? { refreshStatus: data.refreshStatus } : {}), }; } if (type === 'live_artifact_refresh') { return { kind: 'live_artifact_refresh', phase: data.phase, projectId: data.projectId, artifactId: data.artifactId, ...(data.refreshId ? { refreshId: data.refreshId } : {}), ...(data.title ? { title: data.title } : {}), ...(typeof data.refreshedSourceCount === 'number' ? { refreshedSourceCount: data.refreshedSourceCount } : {}), ...(data.error ? { error: data.error } : {}), }; } if (type === 'tool_use' && typeof data.id === 'string' && typeof data.name === 'string') { return { kind: 'tool_use', id: data.id, name: data.name, input: normalizePersistedToolInput(data.input) }; } if (type === 'tool_result' && typeof data.toolUseId === 'string') { return { kind: 'tool_result', toolUseId: data.toolUseId, content: String(data.content ?? ''), isError: Boolean(data.isError), }; } if (type === 'usage') { const usage = data.usage && typeof data.usage === 'object' ? data.usage : {}; return { kind: 'usage', inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, ...(typeof data.costUsd === 'number' ? { costUsd: data.costUsd } : {}), ...(typeof data.durationMs === 'number' ? { durationMs: data.durationMs } : {}), }; } if (type === 'raw' && typeof data.line === 'string') return { kind: 'raw', line: data.line }; return null; } function normalizePersistedToolInput(input) { if (!input || typeof input !== 'object') return input; if ('filePath' in input && typeof input.filePath === 'string') { return { ...input, file_path: input.filePath }; } return input; } function pinAssistantMessageOnRunCreate(db, run) { if (!run.conversationId || !run.assistantMessageId) return; const existing = db .prepare(`SELECT id FROM messages WHERE id = ?`) .get(run.assistantMessageId); if (existing) { db.prepare( `UPDATE messages SET run_id = ?, run_status = CASE WHEN run_status IN ('succeeded', 'failed', 'canceled') THEN run_status ELSE ? END, started_at = COALESCE(started_at, ?) WHERE id = ?`, ).run(run.id, run.status, run.createdAt, run.assistantMessageId); return; } upsertMessage(db, run.conversationId, { id: run.assistantMessageId, role: 'assistant', content: '', agentId: run.agentId ?? undefined, events: [], runId: run.id, runStatus: run.status, startedAt: run.createdAt, }); } export function shouldReportRunCompletedFromMessage(saved, body = {}) { return Boolean( saved && saved.runId && typeof saved.runStatus === 'string' && TERMINAL_RUN_STATUSES.has(saved.runStatus) && body?.telemetryFinalized === true, ); } export function telemetryPromptFromRunRequest(message, currentPrompt) { return typeof currentPrompt === 'string' ? currentPrompt : message; } const FORM_ANSWERS_HEADER_RE = /^\s*\[form answers\s+(?:\u2014|-)\s*([^\]\r\n]+)\]/i; function formAnswerTransitionForCurrentPrompt(currentPrompt) { if (typeof currentPrompt !== 'string') return null; const trimmed = currentPrompt.trim(); if (!trimmed) return null; const match = FORM_ANSWERS_HEADER_RE.exec(trimmed); if (!match) return null; const rawFormId = (match[1] || 'form').trim() || 'form'; const formId = rawFormId.replace(/[^\w.-]/g, '') || 'form'; const lines = [ '## Latest user turn - form answers submitted', trimmed, '', `The user has answered the ${formId} form. Do not emit another ${formId} form.`, ]; if (formId.toLowerCase() === 'discovery') { lines.push( 'Continue with RULE 2 / RULE 3 now. For Branch B answers, build now instead of asking another brief.', ); } else { lines.push( 'Treat these form answers as the active user turn instead of replaying the transcript as a fresh request.', ); } return lines.join('\n'); } export function composeChatUserRequestForAgent(message, currentPrompt) { const body = typeof message === 'string' && message.trim() ? message : '(No extra typed instruction.)'; const transition = formAnswerTransitionForCurrentPrompt(currentPrompt); if (!transition) return body; return [ transition, '## Full conversation transcript', body, ].join('\n\n'); } export function createFinalizedMessageTelemetryReporter({ design, db, dataDir, reportedRuns, getAppVersion = () => null, report = reportRunCompletedFromDaemon, }: { design: any; db: unknown; dataDir: string; reportedRuns: Set; getAppVersion?: () => any; report?: typeof reportRunCompletedFromDaemon; }) { return (saved, body = {}) => { if (!shouldReportRunCompletedFromMessage(saved, body)) return; const run = design.runs.get(saved.runId); if (!run || reportedRuns.has(run.id)) return; reportedRuns.add(run.id); void report({ db, dataDir, run, persistedRunStatus: saved.runStatus, persistedEndedAt: saved.endedAt, appVersion: getAppVersion(), }); }; } const CLOUDFLARE_PAGES_PROJECT_METADATA_KEY = 'cloudflarePagesProjectName'; function cloudflarePagesDeploymentMetadata(projectName) { const normalized = typeof projectName === 'string' ? projectName.trim() : ''; return normalized ? { [CLOUDFLARE_PAGES_PROJECT_METADATA_KEY]: normalized } : undefined; } function cloudflarePagesProjectNameFromDeployment(deployment) { const value = deployment?.providerMetadata?.[CLOUDFLARE_PAGES_PROJECT_METADATA_KEY]; if (typeof value === 'string' && value.trim()) return value.trim(); return cloudflarePagesProjectNameFromUrl(deployment?.url); } function cloudflarePagesProjectNameFromUrl(rawUrl) { if (typeof rawUrl !== 'string' || !rawUrl.trim()) return ''; try { const host = new URL(rawUrl).hostname.toLowerCase(); if (!host.endsWith('.pages.dev')) return ''; const labels = host.slice(0, -'.pages.dev'.length).split('.').filter(Boolean); return labels.at(-1) || ''; } catch { return ''; } } function cloudflarePagesProjectNameForDeploy(db, projectId, projectName, prior) { const priorName = cloudflarePagesProjectNameFromDeployment(prior); if (priorName) return priorName; for (const deployment of listDeployments(db, projectId)) { if (deployment.providerId !== CLOUDFLARE_PAGES_PROVIDER_ID) continue; const stableName = cloudflarePagesProjectNameFromDeployment(deployment); if (stableName) return stableName; } return cloudflarePagesProjectNameForProject(projectId, projectName); } function publicDeployment(deployment) { if (!deployment || typeof deployment !== 'object') return deployment; const { providerMetadata: _providerMetadata, ...publicShape } = deployment; return publicShape; } function publicDeployments(deployments) { return (deployments || []).map(publicDeployment); } async function checkCloudflarePagesDeploymentLinks(existing) { const current = existing.cloudflarePages || {}; const projectName = current.projectName || cloudflarePagesProjectNameFromDeployment(existing); const config = await readDeployConfig(CLOUDFLARE_PAGES_PROVIDER_ID); const pagesDevUrl = current.pagesDev?.url || existing.url; const pagesDevResult = await checkDeploymentUrl(pagesDevUrl); const pagesDev = { ...(current.pagesDev || {}), url: pagesDevUrl, status: pagesDevResult.reachable ? 'ready' : pagesDevResult.status || 'link-delayed', statusMessage: pagesDevResult.reachable ? 'Public link is ready.' : pagesDevResult.statusMessage || current.pagesDev?.statusMessage || 'Cloudflare Pages is still preparing the pages.dev link.', reachableAt: pagesDevResult.reachable ? Date.now() : current.pagesDev?.reachableAt, }; let customDomain = current.customDomain; if (customDomain?.url && customDomain.status !== 'conflict') { let pagesDomain = null; if (config?.token && config?.accountId && projectName) { try { pagesDomain = await readCloudflarePagesDomain({ ...config, projectName }, customDomain.hostname); } catch { pagesDomain = null; } } const customResult = await checkDeploymentUrl(customDomain.url); const pagesDomainStatus = pagesDomain?.status || customDomain.pagesDomainStatus; const failedByApi = ['error', 'blocked', 'deactivated'].includes(String(pagesDomainStatus || '').toLowerCase()); const activeByApi = String(pagesDomainStatus || '').toLowerCase() === 'active'; const readyByReachability = customResult.reachable && activeByApi; customDomain = { ...customDomain, domainStatus: pagesDomain ? pagesDomain.status === 'active' ? 'active' : failedByApi ? 'failed' : 'pending' : customDomain.domainStatus, pagesDomainStatus, validationData: pagesDomain?.validation_data ?? customDomain.validationData, verificationData: pagesDomain?.verification_data ?? customDomain.verificationData, status: readyByReachability ? 'ready' : customDomain.status === 'failed' || failedByApi ? 'failed' : 'pending', statusMessage: readyByReachability ? 'Custom domain is ready.' : failedByApi ? 'Cloudflare Pages reported a custom-domain error.' : customResult.statusMessage || customDomain.statusMessage || 'Custom domain is still being prepared.', }; } const cloudflarePages = { ...current, projectName, pagesDev, ...(customDomain ? { customDomain } : {}), }; const aggregate = aggregateCloudflarePagesStatus(pagesDev, customDomain); return { url: pagesDev.url, status: aggregate.status, statusMessage: aggregate.statusMessage, cloudflarePages, providerMetadata: { ...(existing.providerMetadata || {}), cloudflarePages, }, }; } // Filename slug for the Content-Disposition header on archive downloads. // Browsers reject quotes and control bytes; we keep Unicode letters/digits // so a project name with non-ASCII characters (e.g. "café-design") // survives instead of becoming a row of underscores. function sanitizeArchiveFilename(raw) { const cleaned = String(raw ?? '') .replace(/[\\/:*?"<>|]/g, '_') .replace(/[\u0000-\u001f\u007f]/g, '') .replace(/\s+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80); return cleaned; } function sendLiveArtifactRouteError(res, err) { if (err instanceof LiveArtifactStoreValidationError) { return sendApiError(res, 400, 'LIVE_ARTIFACT_INVALID', err.message, { details: { kind: 'validation', issues: err.issues }, }); } if (err instanceof LiveArtifactRefreshLockError) { return sendApiError(res, 409, 'REFRESH_LOCKED', err.message, { details: { artifactId: err.artifactId }, }); } if (err instanceof LiveArtifactRefreshUnavailableError) { return sendApiError(res, 400, 'LIVE_ARTIFACT_REFRESH_UNAVAILABLE', err.message); } if (err instanceof LiveArtifactRefreshAbortError) { return sendApiError(res, err.kind === 'cancelled' ? 499 : 504, 'LIVE_ARTIFACT_REFRESH_TIMEOUT', err.message, { details: { kind: err.kind, timeoutMs: err.timeoutMs ?? null, step: err.step ?? null }, }); } if (err instanceof ConnectorServiceError) { return sendApiError(res, err.status, err.code, err.message, err.details === undefined ? {} : { details: err.details }); } if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { return sendApiError(res, 404, 'LIVE_ARTIFACT_NOT_FOUND', 'live artifact not found'); } return sendApiError(res, 500, 'LIVE_ARTIFACT_STORAGE_FAILED', String(err)); } function normalizeLocalAuthority(value) { if (typeof value !== 'string') return null; const trimmed = value.trim(); if (!trimmed || /[\s/@]/.test(trimmed) || trimmed.includes(',')) return null; try { const parsed = new URL(`http://${trimmed}`); const hostname = parsed.hostname.toLowerCase().replace(/\.$/, ''); if (!hostname || parsed.username || parsed.password || parsed.pathname !== '/') return null; return { hostname, port: parsed.port }; } catch { return null; } } function isLoopbackHostname(hostname) { const normalized = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); if (normalized === 'localhost') return true; if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true; if (net.isIP(normalized) === 4) return normalized === '127.0.0.1' || normalized.startsWith('127.'); return false; } function isLoopbackPeerAddress(address) { if (typeof address !== 'string') return false; const normalized = address.trim().toLowerCase().replace(/^\[|\]$/g, ''); if (!normalized) return false; if (normalized.startsWith('::ffff:')) return isLoopbackPeerAddress(normalized.slice('::ffff:'.length)); if (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true; if (net.isIP(normalized) === 4) return normalized === '127.0.0.1' || normalized.startsWith('127.'); return false; } function localOriginFromHeader(value) { if (typeof value !== 'string') return null; const trimmed = value.trim(); if (!trimmed || trimmed === 'null' || trimmed.includes(',')) return null; try { const parsed = new URL(trimmed); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; if (parsed.pathname !== '/' || parsed.search || parsed.hash || parsed.username || parsed.password) return null; if (!isLoopbackHostname(parsed.hostname)) return null; return parsed.origin; } catch { return null; } } function validateLocalDaemonRequest(req) { if (!isLoopbackPeerAddress(req.socket?.remoteAddress)) { return { ok: false, message: 'request peer must be a loopback address', details: { peer: 'remoteAddress' }, }; } const host = normalizeLocalAuthority(req.get('host')); if (!host || !isLoopbackHostname(host.hostname)) { return { ok: false, message: 'request host must be a loopback daemon address', details: { header: 'host' }, }; } const originHeader = req.get('origin'); if (originHeader !== undefined && !localOriginFromHeader(originHeader)) { return { ok: false, message: 'request origin must be a loopback daemon origin', details: { header: 'origin' }, }; } return { ok: true, origin: localOriginFromHeader(originHeader) }; } function requireLocalDaemonRequest(req, res, next) { const validation = validateLocalDaemonRequest(req); if (!validation.ok) { return sendApiError(res, 403, 'FORBIDDEN', validation.message, validation.details ? { details: validation.details } : {}); } res.setHeader('Vary', 'Origin'); if (validation.origin) { res.setHeader('Access-Control-Allow-Origin', validation.origin); } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); res.setHeader('Access-Control-Max-Age', '600'); next(); } /** * Render the small HTML page that the OAuth callback returns to the * user's browser tab. It posts a message back to the opener (the * Settings dialog window) and offers a manual close button. We keep * the markup pure HTML/CSS — no external scripts, no React — so the * page works even if the opener was closed and the user just sees a * static success/failure screen. */ function renderOAuthResultPage(opts) { const ok = Boolean(opts.ok); const title = ok ? 'Connected' : 'Authorization failed'; const heading = ok ? '✅ Connected' : '⚠️ Authorization failed'; const body = ok ? `Your MCP server ${escapeHtml(opts.serverId ?? '')} is now connected. You can close this tab and return to Open Design.` : escapeHtml(opts.message ?? 'Authorization could not be completed.'); const accent = ok ? '#1a7f37' : '#cf222e'; const payload = ok ? { type: 'mcp-oauth', ok: true, serverId: opts.serverId ?? null } : { type: 'mcp-oauth', ok: false, message: opts.message ?? null }; return ` ${escapeHtml(title)} — Open Design

${escapeHtml(heading)}

${body}

`; } function escapeHtml(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function setLiveArtifactPreviewHeaders(res) { res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader( 'Content-Security-Policy', [ "default-src 'none'", "base-uri 'none'", "script-src 'none'", "object-src 'none'", "connect-src 'none'", "form-action 'none'", "frame-ancestors 'self'", "img-src 'self' data: blob:", "font-src 'self' data:", "style-src 'unsafe-inline'", 'sandbox allow-same-origin', ].join('; '), ); } function setLiveArtifactCodeHeaders(res) { res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'no-referrer'); } function bearerTokenFromRequest(req) { const header = req.get('authorization'); if (typeof header !== 'string') return undefined; const match = /^Bearer\s+(.+)$/i.exec(header.trim()); return match?.[1]; } function authorizeToolRequest(req, res, operation) { const endpoint = req.path; const validation = toolTokenRegistry.validate(bearerTokenFromRequest(req), { endpoint, operation }); if (!validation.ok) { const status = validation.code === 'TOOL_ENDPOINT_DENIED' || validation.code === 'TOOL_OPERATION_DENIED' ? 403 : 401; sendApiError(res, status, validation.code, validation.message, { details: { endpoint, operation }, }); return null; } return validation.grant; } function requestProjectOverride(projectId, tokenProjectId) { return typeof projectId === 'string' && projectId.length > 0 && projectId !== tokenProjectId; } function requestRunOverride(runId, tokenRunId) { return typeof runId === 'string' && runId.length > 0 && runId !== tokenRunId; } function openNativeFolderDialog() { return new Promise((resolve) => { const platform = process.platform; if (platform === 'darwin') { execFile( 'osascript', ['-e', 'POSIX path of (choose folder with prompt "Select a code folder to link")'], { timeout: 120_000 }, (err, stdout) => { if (err) return resolve(null); const p = stdout.trim().replace(/\/$/, ''); resolve(p || null); }, ); } else if (platform === 'linux') { execFile( 'zenity', ['--file-selection', '--directory', '--title=Select a code folder to link'], { timeout: 120_000 }, (err, stdout) => { if (err) return resolve(null); const p = stdout.trim(); resolve(p || null); }, ); } else if (platform === 'win32') { const command = buildWindowsFolderDialogCommand(); execFile(command.command, command.args, { timeout: 120_000 }, (err, stdout) => { resolve(parseFolderDialogStdout(err, stdout)); }); } else { resolve(null); } }); } /** * @param {ApiErrorCode} code * @param {string} message * @param {Omit} [init] */ function createSseErrorPayload(code, message, init = {}) { return { message, error: createCompatApiError(code, message, init) }; } const UPLOAD_DIR = path.join(os.tmpdir(), 'od-uploads'); fs.mkdirSync(UPLOAD_DIR, { recursive: true }); fs.mkdirSync(ARTIFACTS_DIR, { recursive: true }); const upload = multer({ storage: multer.diskStorage({ destination: UPLOAD_DIR, filename: (_req, file, cb) => { file.originalname = decodeMultipartFilename(file.originalname); const safe = sanitizeName(file.originalname); cb( null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safe}`, ); }, }), limits: { fileSize: 20 * 1024 * 1024 }, }); const importUpload = multer({ storage: multer.diskStorage({ destination: UPLOAD_DIR, filename: (_req, file, cb) => { file.originalname = decodeMultipartFilename(file.originalname); const safe = sanitizeName(file.originalname); cb( null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safe}`, ); }, }), limits: { fileSize: 100 * 1024 * 1024 }, }); const PLUGIN_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; const pluginUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: PLUGIN_UPLOAD_MAX_BYTES, files: 500, fieldSize: 2 * 1024 * 1024, }, }); // Project-scoped multi-file upload. Lands files directly in the project // folder (flat — same shape FileWorkspace expects), so the composer's // pasted/dropped/picked images become referenceable filenames the agent // can Read or @-mention without any cross-folder gymnastics. // Bridge between the multer upload-storage destination (built at module // init) and the per-process project DB (instantiated inside startServer). // startServer() sets this so the upload destination can route attachments // into the right project root, including folder-imported projects whose // files live under metadata.baseDir. let projectMetadataLookup: ((id: string) => Record | null) | null = null; const projectUpload = multer({ storage: multer.diskStorage({ destination: async (req, _file, cb) => { try { // Route uploads into the project's actual root: for folder-imported // projects (metadata.baseDir set) attachments need to land alongside // the user's files so the agent can read them via the same path // it sees. projectMetadataLookup is populated at startServer() boot // and keyed by project id; null fallback gives the standard // .od/projects// behavior for non-imported projects. const meta = projectMetadataLookup?.(req.params.id) ?? null; const dir = await ensureProject(PROJECTS_DIR, req.params.id, meta); cb(null, dir); } catch (err) { cb(err, ''); } }, filename: (_req, file, cb) => { // multer@1 hands us latin1-decoded multipart filenames; restore the // original UTF-8 so the response (and the on-disk name) preserves // non-ASCII characters instead of mangling them. Then run the // shared sanitiser and prepend a base36 timestamp so multiple // uploads with the same original name don't clobber each other. file.originalname = decodeMultipartFilename(file.originalname); const safe = sanitizeName(file.originalname); cb(null, `${Date.now().toString(36)}-${safe}`); }, }), limits: { fileSize: 200 * 1024 * 1024 }, // 200MB — covers the largest design assets we expect (PPTX/PDF/raw images) }); function handleProjectUpload(req, res, next) { projectUpload.array('files', 12)(req, res, (err) => { if (err) { return sendMulterError(res, err); } next(); }); } function sendMulterError(res, err) { if (err instanceof multer.MulterError) { const code = err.code || 'UPLOAD_ERROR'; const statusByCode = { LIMIT_FILE_SIZE: 413, LIMIT_FILE_COUNT: 400, LIMIT_UNEXPECTED_FILE: 400, LIMIT_PART_COUNT: 400, LIMIT_FIELD_KEY: 400, LIMIT_FIELD_VALUE: 400, LIMIT_FIELD_COUNT: 400, MISSING_FIELD_NAME: 400, }; const errorByCode = { LIMIT_FILE_SIZE: 'file too large', LIMIT_FILE_COUNT: 'too many files', LIMIT_UNEXPECTED_FILE: 'unexpected file field', LIMIT_PART_COUNT: 'too many form parts', LIMIT_FIELD_KEY: 'field name too long', LIMIT_FIELD_VALUE: 'field value too long', LIMIT_FIELD_COUNT: 'too many form fields', MISSING_FIELD_NAME: 'missing field name', }; const status = statusByCode[code] ?? 400; const message = errorByCode[code] ?? 'upload failed'; return sendApiError( res, status, code === 'LIMIT_FILE_SIZE' ? 'PAYLOAD_TOO_LARGE' : 'BAD_REQUEST', message, { details: { legacyCode: code } }, ); } if (err) { return sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed'); } return sendApiError(res, 500, 'INTERNAL_ERROR', 'upload failed'); } const mediaTasks = new Map(); const TASK_TTL_AFTER_DONE_MS = 10 * 60 * 1000; const MEDIA_TERMINAL_STATUSES = new Set(['done', 'failed', 'interrupted']); function hydrateMediaTask(row) { const task = { id: row.id, projectId: row.projectId, status: row.status, surface: row.surface, model: row.model, progress: Array.isArray(row.progress) ? row.progress.slice() : [], file: row.file ?? null, error: row.error ?? null, startedAt: row.startedAt, endedAt: row.endedAt, waiters: new Set(), }; mediaTasks.set(task.id, task); return task; } function getLiveMediaTask(db, taskId) { const cached = mediaTasks.get(taskId); if (cached) return cached; const row = getMediaTask(db, taskId); return row ? hydrateMediaTask(row) : null; } function createMediaTask(db, taskId, projectId, info = {}) { const task = { id: taskId, projectId, status: 'queued', surface: info.surface, model: info.model, progress: [], file: null, error: null, startedAt: Date.now(), endedAt: null, waiters: new Set(), }; mediaTasks.set(taskId, task); insertMediaTask(db, { id: taskId, projectId, status: task.status, surface: task.surface, model: task.model, progress: task.progress, file: task.file, error: task.error, startedAt: task.startedAt, endedAt: task.endedAt, }); return task; } function persistMediaTask(db, task) { updateMediaTask(db, task.id, { status: task.status, surface: task.surface, model: task.model, progress: task.progress, file: task.file, error: task.error, startedAt: task.startedAt, endedAt: task.endedAt, }); } function appendTaskProgress(db, task, line) { task.progress.push(line); persistMediaTask(db, task); notifyTaskWaiters(db, task); } function notifyTaskWaiters(db, task) { const wakers = Array.from(task.waiters); for (const w of wakers) { try { w(); } catch { // Never let one bad waiter block the rest. } } if ( MEDIA_TERMINAL_STATUSES.has(task.status) && !task._gcScheduled ) { task._gcScheduled = true; setTimeout(() => { if (task.waiters.size === 0) { mediaTasks.delete(task.id); deleteMediaTask(db, task.id); } }, TASK_TTL_AFTER_DONE_MS).unref?.(); } } function mediaTaskSnapshot(task, since = 0) { const snapshot = { taskId: task.id, status: task.status, startedAt: task.startedAt, endedAt: task.endedAt, progress: task.progress.slice(since), nextSince: task.progress.length, }; if (task.status === 'done') snapshot.file = task.file; if (task.status === 'failed' || task.status === 'interrupted') { snapshot.error = task.error; } return snapshot; } export function createSseResponse( res, { keepAliveIntervalMs = SSE_KEEPALIVE_INTERVAL_MS } = {}, ) { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders?.(); const canWrite = () => !res.destroyed && !res.writableEnded; const writeKeepAlive = () => { if (canWrite()) { res.write(': keepalive\n\n'); return true; } return false; }; let heartbeat = null; if (keepAliveIntervalMs > 0) { heartbeat = setInterval(writeKeepAlive, keepAliveIntervalMs); heartbeat.unref?.(); } const cleanup = () => { if (heartbeat) { clearInterval(heartbeat); heartbeat = null; } }; res.on('close', cleanup); res.on('finish', cleanup); return { /** @param {ChatSseEvent['event'] | ProxySseEvent['event'] | string} event */ send(event, data, id: string | number | null | undefined = null) { if (!canWrite()) return false; // Assemble the full SSE event into a single write so id/event/data land // in one TCP chunk. Three separate writes would let `event: ` flush // ahead of the `data:` payload, which produces partial events for // consumers that read chunk-by-chunk (e.g. tests using a Response body // reader with a substring marker). const idLine = id !== null && id !== undefined ? `id: ${id}\n` : ''; res.write(`${idLine}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); return true; }, writeKeepAlive, cleanup, end() { cleanup(); if (canWrite()) { res.end(); } }, }; } export type DesktopPdfExporter = (input: DesktopExportPdfInput) => Promise; // Loosely typed shape — we only access `namespace`, `base`, `mode`, and // `source` from the runtime context when building the diagnostics export. // Anything richer would force a dependency from server.ts into the sidecar // package, which the boundary checks explicitly forbid. export interface DaemonRuntimeContext { namespace: string; base: string; mode?: string; source?: string; } export interface StartServerOptions { desktopPdfExporter?: DesktopPdfExporter | null; host?: string; port?: number; returnServer?: boolean; runtime?: DaemonRuntimeContext | null; } const DEFAULT_CHAT_RUN_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; const MAX_CHAT_RUN_INACTIVITY_TIMEOUT_MS = 24 * 60 * 60 * 1000; function resolveChatRunInactivityTimeoutMs() { const raw = Number(process.env.OD_CHAT_RUN_INACTIVITY_TIMEOUT_MS); // This watchdog observes child stdout/stderr/SSE activity, not real CPU or // filesystem progress. Keep the default long enough for agents that spend // several minutes silently writing large artifacts. if (!Number.isFinite(raw)) return DEFAULT_CHAT_RUN_INACTIVITY_TIMEOUT_MS; // Node clamps delays larger than a signed 32-bit integer down to 1ms, which // makes an oversized override fail almost immediately while reporting a huge // timeout. Keep explicit overrides bounded to a practical, timer-safe value. return Math.min(MAX_CHAT_RUN_INACTIVITY_TIMEOUT_MS, Math.max(0, Math.floor(raw))); } function resolveChatRunShutdownGraceMs() { const raw = Number(process.env.OD_CHAT_RUN_SHUTDOWN_GRACE_MS); if (!Number.isFinite(raw)) return 3_000; return Math.max(0, Math.floor(raw)); } function resolveAcpStageTimeoutMs(): number | undefined { // Per-stage silence watchdog for ACP chat sessions. Defaults are owned by // `attachAcpSession` in acp.ts; this resolver only applies when an operator // sets `OD_ACP_STAGE_TIMEOUT_MS`. Bounded to the same 24h ceiling as the // outer chat inactivity watchdog so an oversized override doesn't get // clamped to 1ms by Node's signed-32-bit delay limit. const raw = Number(process.env.OD_ACP_STAGE_TIMEOUT_MS); if (!Number.isFinite(raw)) return undefined; return Math.min(MAX_CHAT_RUN_INACTIVITY_TIMEOUT_MS, Math.max(0, Math.floor(raw))); } export async function startServer({ port = 7456, host = process.env.OD_BIND_HOST || '127.0.0.1', returnServer = false, desktopPdfExporter = null, runtime = null, }: StartServerOptions = {}) { let resolvedPort = port; let daemonShuttingDown = false; const extraAllowedOrigins = configuredAllowedOrigins(); // Plan §3.K1 / spec §15.7 — bound-API-token guard. // // The daemon refuses to bind to a public interface unless an // OD_API_TOKEN is set. This is the spec §16 Phase 5 safety floor: // a hosted operator can no longer accidentally publish an unsecured // daemon by setting OD_BIND_HOST=0.0.0.0 without a token. // // Loopback hosts (127.0.0.1 / ::1 / localhost) are always allowed — // the desktop / dev flow remains unchanged. Setting OD_API_TOKEN is // purely additive: when present, every /api/* request must carry a // matching `Authorization: Bearer ` header (loopback origins // are exempted so the desktop UI keeps working). const apiToken = (process.env.OD_API_TOKEN ?? '').trim(); if (!isLoopbackHostname(host) && apiToken.length === 0) { throw new Error( `OD_BIND_HOST=${host} requires OD_API_TOKEN to be set. ` + `Generate one with \`openssl rand -hex 32\` and re-launch. ` + `(Loopback hosts 127.0.0.1 / ::1 / localhost do not need a token.)`, ); } const app = express(); app.use(express.json({ limit: '4mb' })); // Plan §3.K1 — bearer-token middleware. // // Active only when OD_API_TOKEN is set. Loopback origins skip the // check (the desktop UI / local CLI never carry a bearer); every // other request must present `Authorization: Bearer ` with a // value matching `OD_API_TOKEN`. Health / version / status remain // open so monitoring probes don't need the token. if (apiToken.length > 0) { const openProbePaths = new Set(['/api/health', '/api/version', '/api/daemon/status']); app.use('/api', (req, res, next) => { if (openProbePaths.has(req.path)) return next(); // Loopback short-circuit. We ignore the proxied X-Forwarded-For // header here because a reverse proxy MUST always forward the // bearer; the loopback bypass exists for the localhost desktop // UI which has no proxy in the path. if (isLoopbackPeerAddress(req.socket?.remoteAddress)) return next(); const auth = req.get('authorization') ?? ''; const match = /^Bearer\s+(\S+)\s*$/i.exec(auth); if (!match || match[1] !== apiToken) { return res.status(401).json({ error: { code: 'API_TOKEN_REQUIRED', message: 'Authorization: Bearer required' }, }); } return next(); }); } // Multi-directory scanning shared by every skill / template surface. The // helpers delegate to listSkills(roots) which walks roots in priority // order, tags each entry with the SkillSource ('user' for the user // root, 'built-in' for the bundled root) the contracts package // declares, and lets a user-imported entry shadow a built-in one of // the same id without erasing the built-in copy. async function listAllSkills() { return listSkills(SKILL_ROOTS); } async function listAllDesignTemplates() { return listSkills(DESIGN_TEMPLATE_ROOTS); } // Spans both roots so chat run system-prompt composition and the orbit // template resolver can resolve a stored project.skillId regardless of // which surface created the project after the skills/design-templates // split. Keep in sync with SKILL_ROOTS + DESIGN_TEMPLATE_ROOTS above. async function listAllSkillLikeEntries() { return listSkills(ALL_SKILL_LIKE_ROOTS); } async function listAllDesignSystems() { const builtIn = (await listDesignSystems(DESIGN_SYSTEMS_DIR)).map((s) => ({ ...s, source: 'built-in', isEditable: false, status: 'published', })); let installed = []; try { installed = await listDesignSystems(USER_DESIGN_SYSTEMS_DIR, { idPrefix: 'user:', source: 'user', isEditable: true, defaultStatus: 'draft', }); } catch { // User directory may not exist yet or be unreadable. } const seen = new Set(builtIn.map((s) => s.id)); return [ ...installed .filter((s) => s.source === 'user') .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')), ...builtIn, ...installed.filter((s) => s.source !== 'user' && !seen.has(s.id)), ]; } async function readAvailableDesignSystem(id) { if (typeof id === 'string' && id.startsWith('user:')) { return readDesignSystem(USER_DESIGN_SYSTEMS_DIR, id, { idPrefix: 'user:' }); } return ( (await readDesignSystem(DESIGN_SYSTEMS_DIR, id)) ?? (await readDesignSystem(USER_DESIGN_SYSTEMS_DIR, id)) ); } async function readAvailableDesignSystemPackageInfo(id) { if (typeof id === 'string' && id.startsWith('user:')) { return readDesignSystemPackageInfo(USER_DESIGN_SYSTEMS_DIR, id, { idPrefix: 'user:' }); } return ( (await readDesignSystemPackageInfo(DESIGN_SYSTEMS_DIR, id)) ?? (await readDesignSystemPackageInfo(USER_DESIGN_SYSTEMS_DIR, id)) ); } function isProjectUsableDesignSystem(summary) { return summary?.status !== 'draft'; } async function validateProjectDesignSystemId(id) { if (id === undefined || id === null || id === '') return { ok: true, id: null }; if (typeof id !== 'string') { return { ok: false, code: 'INVALID_DESIGN_SYSTEM', message: 'designSystemId must be a string or null', }; } const systems = await listAllDesignSystems(); const summary = systems.find((system) => system.id === id); if (!summary) { return { ok: false, code: 'DESIGN_SYSTEM_NOT_FOUND', message: 'design system not found', }; } if (!isProjectUsableDesignSystem(summary)) { return { ok: false, code: 'DESIGN_SYSTEM_NOT_PUBLISHED', message: 'draft design systems cannot be used by projects', }; } return { ok: true, id }; } function userDesignSystemWorkspaceProjectId(id) { if (typeof id !== 'string' || !id.startsWith('user:')) return null; const dirId = id.slice('user:'.length); if (!/^[A-Za-z0-9._-]{1,120}$/.test(dirId)) return null; return `ds-${dirId}`.slice(0, 128); } function projectBackedDesignSystemProjectId(id, summary) { if (typeof summary?.projectId === 'string' && isSafeId(summary.projectId)) { return summary.projectId; } return userDesignSystemWorkspaceProjectId(id); } async function ensureUserDesignSystemWorkspaceProject(db, id) { const systems = await listAllDesignSystems(); const summary = systems.find((s) => s.id === id && s.source === 'user'); if (!summary) return null; const projectId = projectBackedDesignSystemProjectId(id, summary); if (!projectId) return null; const now = Date.now(); const metadata = { kind: 'other', importedFrom: 'design-system', entryFile: 'DESIGN.md', sourceFileName: id, }; const existing = getProject(db, projectId); const project = existing ? updateProject(db, projectId, { name: summary.title, designSystemId: id, metadata: { ...existing.metadata, ...metadata }, updatedAt: now, }) : insertProject(db, { id: projectId, name: summary.title, skillId: null, designSystemId: id, pendingPrompt: null, metadata, createdAt: now, updatedAt: now, }); if (!project) return null; const files = await listUserDesignSystemFiles(USER_DESIGN_SYSTEMS_DIR, id); if (!files) return null; for (const file of files) { if (file.kind === 'folder') continue; const detail = await readUserDesignSystemFile(USER_DESIGN_SYSTEMS_DIR, id, file.path); if (!detail) continue; if (existing) { try { const existingFile = await readProjectFile(PROJECTS_DIR, projectId, detail.path, project.metadata); if (!isReplaceableDesignSystemWorkspaceFile(detail.path, existingFile)) continue; } catch (err) { if (!err || err.code !== 'ENOENT') throw err; } } await writeProjectFile( PROJECTS_DIR, projectId, detail.path, Buffer.from(detail.content, 'utf8'), {}, project.metadata, ); } await removeLegacyDesignSystemWorkspaceArtifacts(project); await linkUserDesignSystemProject(USER_DESIGN_SYSTEMS_DIR, id, project.id); const projectFiles = await listFiles(PROJECTS_DIR, projectId, { metadata: project.metadata }); return { project, files: projectFiles }; } function isReplaceableDesignSystemWorkspaceFile(filePath, file) { const buffer = file?.buffer; if (!Buffer.isBuffer(buffer)) return false; const text = buffer.toString('utf8'); if (/^ui_kits\/app\/components\/.+\.(jsx|tsx|js|ts|css|html)$/u.test(filePath)) { return buffer.length < 700 && /od-ui-kit-[a-z-]+/u.test(text); } if (!/^(DESIGN\.md|README\.md|SKILL\.md|ui_kits\/app\/README\.md)$/u.test(filePath)) { return false; } return hasLegacyDesignSystemPackageReferences(text); } function hasLegacyDesignSystemPackageReferences(text) { return /preview\/(colors-node-types|colors-ui-palette|typography-scale|spacing-system|logo-variants)\.html|ui_kits\/generated_interface(?:\/index\.html|\/)?/u.test(text); } async function removeLegacyDesignSystemWorkspaceArtifacts(project) { if (project?.metadata?.importedFrom !== 'design-system') return; const dir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata); for (const artifact of LEGACY_DESIGN_SYSTEM_ARTIFACTS) { const replacementReady = await Promise.all( artifact.replacementPaths.map(async (replacementPath) => { try { const stats = await fs.promises.stat(path.join(dir, ...replacementPath.split('/'))); return stats.isFile(); } catch (err) { if (!err || (err.code !== 'ENOENT' && err.code !== 'ENOTDIR')) throw err; return false; } }), ); if (!replacementReady.every(Boolean)) continue; await fs.promises.rm(path.join(dir, ...artifact.legacyPath.split('/')), { recursive: artifact.removeDirectory === true, force: true, }); } } async function readDesignSystemWorkspaceTextFile(db, summary, filePath) { if (!summary?.projectId || !isSafeId(summary.projectId)) return null; const project = getProject(db, summary.projectId); if (!project) return null; try { const file = await readProjectFile( PROJECTS_DIR, project.id, filePath, project.metadata, ); const text = file.buffer.toString('utf8'); if (text.includes('\0')) return null; return text; } catch { return null; } } // Chrome may strip the port from the Origin header on same-origin GET // requests. Only use this as a fallback for safe, idempotent GET requests; // mutating routes always require an exact origin/host match. function isPortlessLoopbackOrigin(origin) { return /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])$/.test(origin); } // Routes that serve content to sandboxed iframes (Origin: null) for // read-only purposes. All other /api routes reject Origin: null. const _NULL_ORIGIN_SAFE_GET_RE = /^\/projects\/[^/]+\/raw\/|^\/codex-pets\/[^/]+\/spritesheet$/; // Reject cross-origin requests to API endpoints. // Health/version remain open for monitoring probes. // Non-browser clients (no Origin header) are always allowed. app.use('/api', (req, res, next) => { // Live artifact previews have stricter local-daemon validation and // loopback CORS handling on the route itself. Let that middleware produce // the structured error shape and preflight headers for preview embeds. if (/^\/live-artifacts\/[^/]+\/preview$/.test(req.path)) return next(); const origin = req.headers.origin; // Non-browser client → allow. if (origin == null || origin === '') return next(); // Origin: null (sandboxed iframes). Only allowed for safe, read-only // routes that set their own CORS headers for canvas drawing. if (origin === 'null') { const isSafeReadOnly = req.method === 'GET' && _NULL_ORIGIN_SAFE_GET_RE.test(req.path); if (!isSafeReadOnly) { return res.status(403).json({ error: 'Origin: null not allowed for this route' }); } return next(); } // Fail-closed: block all browser origins until port is resolved. if (!resolvedPort) { return res.status(403).json({ error: 'Server initializing' }); } const ports = allowedBrowserPorts(resolvedPort); if (!isAllowedBrowserOrigin(origin, req.headers.host, ports, host, extraAllowedOrigins)) { if (req.method !== 'GET' || !isPortlessLoopbackOrigin(String(origin))) { return res.status(403).json({ error: 'Cross-origin requests are not allowed' }); } } next(); }); const db = openDatabase(PROJECT_ROOT, { dataDir: RUNTIME_DATA_DIR }); // Wire the upload-destination bridge to this db so multer can route // file uploads into baseDir-rooted projects' actual folders. projectMetadataLookup = (id) => { try { return getProject(db, id)?.metadata ?? null; } catch { return null; } }; configureConnectorCredentialStore(new FileConnectorCredentialStore(RUNTIME_DATA_DIR)); configureComposioConfigStore(RUNTIME_DATA_DIR); composioConnectorProvider.configureCatalogCache(RUNTIME_DATA_DIR); composioConnectorProvider.startCatalogRefreshLoop(); // RoutineService persistence is a thin adapter over the SQLite helpers. // Routines are stored as DB rows; the service holds in-memory timers and // delegates "list me everything" / "record a run" back to SQLite. routineService = new RoutineService({ list: () => listRoutines(db).map((row) => routineDbRowToContract(row, null)), insertRun: (run) => { insertRoutineRun(db, { id: run.id, routineId: run.routineId, trigger: run.trigger, status: run.status, projectId: run.projectId, conversationId: run.conversationId, agentRunId: run.agentRunId, startedAt: run.startedAt, completedAt: run.completedAt, summary: run.summary, error: run.error, errorCode: run.errorCode, }); }, updateRun: (id, patch) => { updateRoutineRun(db, id, patch); }, getLatestRun: (routineId) => getLatestRoutineRun(db, routineId), }); let daemonUrl = `http://127.0.0.1:${port}`; // Boot reconcile: any critique_runs row left in 'running' state by a prior // daemon crash gets flipped to 'interrupted' with rounds_json.recoveryReason // = 'daemon_restart' so the spec's daemon-restart-mid-run failure mode is // honored on every boot. staleAfterMs comes from CritiqueConfig, not a // hardcoded constant. const reconciledStaleRuns = reconcileStaleRuns(db, { staleAfterMs: critiqueCfg.totalTimeoutMs }); if (reconciledStaleRuns > 0) { console.warn(`[critique] reconcileStaleRuns flipped ${reconciledStaleRuns} stale running row(s) to interrupted`); } const mediaReconcile = reconcileMediaTasksOnBoot(db, { terminalTtlMs: TASK_TTL_AFTER_DONE_MS, }); if (mediaReconcile.interrupted > 0 || mediaReconcile.deleted > 0) { console.warn( `[media] reconcileMediaTasksOnBoot interrupted ${mediaReconcile.interrupted} task(s), ` + `deleted ${mediaReconcile.deleted} expired terminal task(s)`, ); } mediaTasks.clear(); for (const row of listRecentMediaTasks(db, { terminalTtlMs: TASK_TTL_AFTER_DONE_MS })) { hydrateMediaTask(row); } if (process.env.OD_CODEX_DISABLE_PLUGINS === '1') { console.log('[od] Codex plugins disabled via OD_CODEX_DISABLE_PLUGINS=1'); } let bundledMarketplaceEntries = []; // Plan §3.I3 / spec §23.3.5 — register every plugin under // /plugins/_official/** in packaged runs, or // /plugins/_official/** in workspace runs, as bundled plugins. The walker // is idempotent (upserts on every boot) so a daemon upgrade rotates // the bundled set in lockstep with the code. ENOENT is silent — // running the daemon outside the dev tree just skips this step. try { const result = await registerBundledPlugins({ db, bundledRoot: BUNDLED_PLUGINS_DIR, marketplaceProvenance: { sourceMarketplaceId: OFFICIAL_MARKETPLACE_ID, marketplaceTrust: 'official', entryNamePrefix: 'open-design', }, }); bundledMarketplaceEntries = result.registered.map((plugin) => ({ name: `open-design/${plugin.id}`, title: plugin.title, description: plugin.description, version: plugin.version, source: bundledPluginRegistrySource(plugin.source), publisher: { id: 'open-design', url: 'https://open-design.ai' }, homepage: plugin.manifest.homepage, license: plugin.manifest.license, tags: plugin.tags, capabilitiesSummary: Array.isArray(plugin.manifest.od?.capabilities) ? plugin.manifest.od.capabilities : undefined, })); if (result.registered.length > 0) { console.log(`[plugins] registered ${result.registered.length} bundled plugin(s)`); } if (result.warnings.length > 0) { for (const w of result.warnings) console.warn(`[plugins] bundled warn: ${w}`); } } catch (err) { console.warn(`[plugins] bundled registration failed: ${(err)?.message ?? err}`); } try { const seedDirs = await fs.promises.readdir(PLUGIN_REGISTRY_DIR, { withFileTypes: true }).catch((err) => { if (err?.code === 'ENOENT') return []; throw err; }); const { ensureMarketplaceManifest } = await import('./plugins/marketplaces.js'); for (const dirent of seedDirs) { if (!dirent.isDirectory()) continue; const id = dirent.name; const manifestText = await marketplaceSeedManifestText(id, bundledMarketplaceEntries); if (!manifestText) continue; const configured = defaultMarketplaceSeedConfig(id); const result = ensureMarketplaceManifest(db, { id, url: configured.url, trust: configured.trust, manifestText, }); if (result.ok) { console.log(`[plugins] seeded ${id} registry source (${result.row.manifest.plugins.length} plugin(s))`); } else { console.warn(`[plugins] ${id} registry seed failed: ${result.message}`); } } } catch (err) { console.warn(`[plugins] registry seed failed: ${(err)?.message ?? err}`); } // Plan §3.A5 / spec §16 Phase 5 / PB2: periodic snapshot GC. Disabled // when OD_SNAPSHOT_GC_INTERVAL_MS is 0; otherwise one-time bootstrap // sweep + interval. The function returns a NOOP_HANDLE when disabled // so we don't have to branch on the result. const snapshotGc = startSnapshotGc({ db }); // One immediate sweep so a daemon that just gained the ALTER doesn't // wait the full interval before reaping pre-existing expired rows. try { const initialSweep = pruneExpiredSnapshots(db); if (initialSweep.removed > 0) { console.log(`[plugins] snapshot GC startup sweep removed ${initialSweep.removed} row(s)`); } } catch (err) { console.warn(`[plugins] snapshot GC startup sweep failed: ${(err)?.message ?? err}`); } void snapshotGc; // keep handle alive for the daemon's lifetime // Warm agent-capability probes (e.g. whether the installed Claude Code // build advertises --include-partial-messages) so the first /api/chat // hits a populated cache even if /api/agents hasn't been called yet. void readAppConfig(RUNTIME_DATA_DIR) .then((config) => { orbitService.configure(config.orbit); return detectAgents(config.agentCliEnv ?? {}); }) .catch(() => detectAgents().catch(() => {})); await recoverStaleLiveArtifactRefreshes({ projectsRoot: PROJECTS_DIR }).catch((error) => { console.warn('[od] Failed to recover stale live artifact refreshes:', error); }); if (fs.existsSync(STATIC_DIR)) { app.use(express.static(STATIC_DIR)); } app.get('/api/health', async (_req, res) => { const versionInfo = await readCurrentAppVersionInfo(); res.json({ ok: true, version: versionInfo.version }); }); app.get('/api/version', async (_req, res) => { const version = await readCurrentAppVersionInfo(); res.json({ version }); }); // Plan §3.F2 / spec §11.7 — daemon lifecycle status. Returns the // host / port the server is bound to plus the data dir, // so `od daemon status --json` can render a one-shot health snapshot // without depending on /api/version's content shape. app.get('/api/daemon/status', async (_req, res) => { const versionInfo = await readCurrentAppVersionInfo(); res.json({ ok: true, version: versionInfo.version, bindHost: process.env.OD_BIND_HOST ?? '127.0.0.1', port: Number(process.env.OD_PORT ?? 7456), dataDir: RUNTIME_DATA_DIR, mediaConfigDir: process.env.OD_MEDIA_CONFIG_DIR ?? null, pid: process.pid, shuttingDown: daemonShuttingDown, installedPlugins: (() => { try { return (db.prepare('SELECT COUNT(*) AS n FROM installed_plugins').get())?.n ?? 0; } catch { return 0; } })(), }); }); // Plan §3.GG1 — `od daemon db status`. Inventory of the SQLite // backend: file path, size on disk (primary + WAL + SHM), schema // version (the user_version PRAGMA we use for migrations), and // per-table row counts. Useful for ops sanity-checking // deployments + comparing 'expected' vs. 'actual' table rosters. app.get('/api/daemon/db', async (_req, res) => { try { const { inspectSqliteDatabase } = await import('./storage/db-inspect.js'); const file = path.join(RUNTIME_DATA_DIR, 'app.sqlite'); const report = await inspectSqliteDatabase({ db, file }); res.json(report); } catch (err) { res.status(500).json({ error: String(err) }); } }); // Plan §3.KK1 — non-SSE one-shot read of the event ring buffer. // Useful for dashboards + the `od plugin events snapshot` CLI // command that doesn't need a live tail. app.get('/api/plugins/events/snapshot', async (req, res) => { const since = Number(typeof req.query.since === 'string' ? req.query.since : 0); const { pluginEventSnapshot } = await import('./plugins/events.js'); const events = pluginEventSnapshot(Number.isFinite(since) && since > 0 ? since : 0); res.json({ events, count: events.length, generatedAt: Date.now() }); }); // Plan §3.KK2 — rolled-up stats over the buffer. Counts by kind + // pluginId + oldest/newest timestamps + id range. app.get('/api/plugins/events/stats', async (_req, res) => { const { pluginEventSnapshot, summarisePluginEvents } = await import('./plugins/events.js'); res.json({ stats: summarisePluginEvents(pluginEventSnapshot()), generatedAt: Date.now(), }); }); // Plan §3.NN1 — `od plugin events purge`. Operator escape // hatch for resetting the in-memory ring buffer. Loopback-only // because clearing the buffer drops audit history; an operator // with shell access to the daemon machine should be the only // one allowed to invoke. Returns the pre-purge stats so the // caller can confirm what they discarded. app.post('/api/plugins/events/purge', requireLocalDaemonRequest, async (_req, res) => { try { const { purgePluginEventBuffer } = await import('./plugins/events.js'); const result = purgePluginEventBuffer(); res.json({ ok: true, ...result }); } catch (err) { res.status(500).json({ error: String(err) }); } }); // Plan §3.II1 — `od plugin events tail`. SSE-backed live event // stream of plugin lifecycle events from the in-memory ring // buffer. On open: emits the buffered backlog as 'event: backlog' // entries (capped at the buffer's MAX), then forwards every // newly-recorded event as 'event: plugin' with the same shape. // Optional ?since= trims the backlog. app.get('/api/plugins/events', async (req, res) => { const since = Number(typeof req.query.since === 'string' ? req.query.since : 0); const { pluginEventSnapshot, subscribePluginEvents } = await import('./plugins/events.js'); res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders?.(); // Emit the backlog so a tail consumer doesn't miss installs // that happened just before they connected. const backlog = pluginEventSnapshot(Number.isFinite(since) && since > 0 ? since : 0); for (const ev of backlog) { res.write(`event: backlog\ndata: ${JSON.stringify(ev)}\n\n`); } const unsubscribe = subscribePluginEvents((ev) => { res.write(`event: plugin\ndata: ${JSON.stringify(ev)}\n\n`); }); req.on('close', () => { unsubscribe(); }); }); // Plan §3.LL1 — `od daemon db verify`. Runs SQLite // PRAGMA integrity_check (or quick_check when ?quick=1) + // PRAGMA foreign_key_check, returns a structured issues[] // report. Loopback-only via requireLocalDaemonRequest because // the result reveals storage-layer state. app.post('/api/daemon/db/verify', requireLocalDaemonRequest, async (req, res) => { try { const { verifySqliteIntegrity } = await import('./storage/db-inspect.js'); const quick = String(req.query.quick ?? '').toLowerCase(); const report = verifySqliteIntegrity({ db, quick: quick === '1' || quick === 'true' }); res.json(report); } catch (err) { res.status(500).json({ error: String(err) }); } }); // Plan §3.HH2 — `od daemon db vacuum`. Runs SQLite VACUUM to // reclaim space after large delete batches (snapshot prune, // plugin uninstall, etc.). Reports before / after sizes so the // operator sees the reclamation, plus elapsed ms so a slow // VACUUM on a big DB is visible. app.post('/api/daemon/db/vacuum', requireLocalDaemonRequest, async (_req, res) => { try { const { inspectSqliteDatabase } = await import('./storage/db-inspect.js'); const file = path.join(RUNTIME_DATA_DIR, 'app.sqlite'); const before = await inspectSqliteDatabase({ db, file }); const startedAt = Date.now(); // VACUUM cannot run inside an active transaction; better-sqlite3 // exposes it as a regular pragma exec. db.exec('VACUUM'); const elapsedMs = Date.now() - startedAt; const after = await inspectSqliteDatabase({ db, file }); res.json({ ok: true, beforeBytes: before.sizeBytes, afterBytes: after.sizeBytes, reclaimedBytes: Math.max(0, before.sizeBytes - after.sizeBytes), elapsedMs, }); } catch (err) { res.status(500).json({ error: String(err) }); } }); // Plan §3.F2 — graceful shutdown. The CLI calls this from // `od daemon stop`; the actual close path goes through the same // SIGTERM-equivalent flow as a parent-process kill (the boot wrapper // in cli.ts wires the process listeners). 202 Accepted because the // shutdown completes after the response flush. app.post('/api/daemon/shutdown', requireLocalDaemonRequest, (_req, res) => { res.status(202).json({ ok: true, scheduled: true }); setImmediate(() => { try { process.emit('SIGTERM'); } catch { // Best-effort; if the listener was removed (or the process is // mid-shutdown already) the kernel SIGTERM falls back below. } }); }); // Prometheus scrape endpoint (Phase 12). Returns the full exposition // format string. Operators put this behind their existing auth proxy; // there is no built-in authn on the daemon HTTP server. To disable // the endpoint entirely (air-gapped installs, regulatory contexts), // set `OD_METRICS_ENDPOINT=disabled`; the route is registered only // when that env value is not the literal string 'disabled'. if (process.env.OD_METRICS_ENDPOINT !== 'disabled') { app.get('/api/metrics', async (_req, res) => { res.setHeader('Content-Type', register.contentType); res.send(await getCritiqueMetrics()); }); } // Phase 16 ratchet endpoint. Returns the rolling conformance window // and the ratchet's current recommendation. Operator-driven by // design: the recommendation does not flip OD_CRITIQUE_ROLLOUT_PHASE // automatically, it surfaces so a deploy-pipeline follow-up can // consume it. Tunables come from query string; defaults are the // spec values (14 days, 0.90 shipped, 0.95 clean-parse). // Codex + lefarcen P1 on PR #1499: clamp query inputs before the // evaluator sees them so a request like `?windowDays=0` falls back to // the spec default rather than producing a zero-evidence promotion. // The evaluator also defends at its own entry; both are intentional // (belt + suspenders) so a future caller that bypasses this route // cannot reach an unguarded code path either. const parsePositiveInt = (raw: unknown, fallback: number): number => { if (typeof raw !== 'string' || raw.length === 0) return fallback; const n = Number(raw); return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; }; const parseRate = (raw: unknown, fallback: number): number => { if (typeof raw !== 'string' || raw.length === 0) return fallback; const n = Number(raw); return Number.isFinite(n) && n >= 0 && n <= 1 ? n : fallback; }; app.get('/api/critique/conformance', async (req, res) => { try { const windowDays = parsePositiveInt(req.query.windowDays, 14); const shippedThreshold = parseRate(req.query.shippedThreshold, 0.90); const cleanParseThreshold = parseRate(req.query.cleanParseThreshold, 0.95); const history = await readConformanceHistory(RUNTIME_DATA_DIR, windowDays); const decision = evaluateRollout({ current: parseRolloutPhase(process.env.OD_CRITIQUE_ROLLOUT_PHASE), history, windowDays, shippedThreshold, cleanParseThreshold, }); res.json({ window: { days: windowDays, history }, decision }); } catch (err) { sendApiError(res, 500, 'INTERNAL_ERROR', err instanceof Error ? err.message : String(err)); } }); registerConnectorRoutes(app, { sendApiError, authorizeToolRequest, projectsRoot: PROJECTS_DIR, requireLocalDaemonRequest, composio: composioConnectorProvider, }); // Gate the diagnostics export behind requireLocalDaemonRequest so it stays // unreachable when daemon binds to a non-loopback address (Tailscale, // 0.0.0.0, etc.). The bundle contains daemon/web/desktop logs, host // metadata, and crash reports — same threat tier as connector / live- // artifact endpoints, which all use the same guard. app.get( DIAGNOSTICS_EXPORT_PATH, requireLocalDaemonRequest, createDiagnosticsExportHandler({ runtime, projectRoot: PROJECT_ROOT }), ); // ---- Projects (DB-backed) ------------------------------------------------- // ----- Memory store ----------------------------------------------------- // Markdown-on-disk memory under /memory/. The daemon folds these // into every system prompt (gated by `enabled`) and the chat run loop // calls `/api/memory/extract` after each turn to sediment new facts. app.get('/api/memory', async (_req, res) => { try { const [config, index, entries] = await Promise.all([ readMemoryConfig(RUNTIME_DATA_DIR), readMemoryIndex(RUNTIME_DATA_DIR), listMemoryEntries(RUNTIME_DATA_DIR), ]); res.json({ enabled: config.enabled, chatExtractionEnabled: config.chatExtractionEnabled, rootDir: memoryDir(RUNTIME_DATA_DIR), index, entries, extraction: maskMemoryExtractionConfig(config.extraction), }); } catch (err) { res.status(500).json({ error: String(err) }); } }); // Static sub-resources (`/index`, `/config`, `/extract`) registered // BEFORE the `:id` catch-alls so an `index` / `config` / `extract` slug // can't shadow the real handlers. app.get('/api/memory/tree', async (_req, res) => { try { const [config, tree] = await Promise.all([ readMemoryConfig(RUNTIME_DATA_DIR), buildMemoryTree(RUNTIME_DATA_DIR), ]); res.json({ enabled: config.enabled, rootDir: memoryDir(RUNTIME_DATA_DIR), tree, }); } catch (err) { res.status(500).json({ error: String((err && err.message) || err) }); } }); app.patch('/api/memory/tree/:id', async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? req.body : {}; const entry = await updateMemoryTreeNode( RUNTIME_DATA_DIR, req.params.id, body, ); const tree = await buildMemoryTree(RUNTIME_DATA_DIR); res.json({ entry, tree }); } catch (err) { const message = String((err && err.message) || err); res.status(message === 'memory not found' ? 404 : 400).json({ error: message }); } }); app.put('/api/memory/index', async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? req.body : {}; const index = typeof body.index === 'string' ? body.index : ''; await writeMemoryIndex(RUNTIME_DATA_DIR, index); res.json({ index }); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); app.patch('/api/memory/config', async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? req.body : {}; const patch = {}; if (typeof body.enabled === 'boolean') patch.enabled = body.enabled; if (typeof body.chatExtractionEnabled === 'boolean') { patch.chatExtractionEnabled = body.chatExtractionEnabled; } // Three-state extraction handling so the UI can: (a) leave the // override alone (omit `extraction`), (b) clear it back to // auto-pick (`extraction: null`), or (c) commit a custom override // (`extraction: { provider, ... }`). For the apiKey field we // need *four* states because the masked GET surfaces only an // `apiKeyTail` (the secret never round-trips): // - field absent → preserve the stored key (UI re-saves // a settings form without re-typing // the secret). // - field === '' → CLEAR the stored key (the picker's // drift-resync effect fires this when // the user clears their BYOK chat // API key — keeping the old daemon- // side credential would silently keep // calling the provider after the user // intentionally removed it from the // chat picker, which the reviewer // flagged as a credential-sync bug). // - field === 'sk-…' → replace with the new key. // - provider differs → ignore stored key entirely. if (Object.prototype.hasOwnProperty.call(body, 'extraction')) { if (body.extraction === null) { patch.extraction = null; } else if (body.extraction && typeof body.extraction === 'object') { const incoming = body.extraction; const current = await readMemoryConfig(RUNTIME_DATA_DIR); const apiKeyOmitted = !Object.prototype.hasOwnProperty.call( incoming, 'apiKey', ); const sameProvider = !!current.extraction && current.extraction.provider === incoming.provider; let nextApiKey = ''; if (typeof incoming.apiKey === 'string' && incoming.apiKey) { nextApiKey = incoming.apiKey; } else if (apiKeyOmitted && sameProvider) { nextApiKey = current.extraction.apiKey ?? ''; } patch.extraction = { provider: incoming.provider, model: typeof incoming.model === 'string' ? incoming.model : undefined, baseUrl: typeof incoming.baseUrl === 'string' ? incoming.baseUrl : undefined, apiKey: nextApiKey, // Azure-only; ignored by the validator for the other providers. // We forward whatever the UI sent (or the previously-stored // value when the UI omits the field) so re-saving an azure // override without re-typing the api-version doesn't blank it. apiVersion: typeof incoming.apiVersion === 'string' ? incoming.apiVersion : current.extraction?.apiVersion, }; } } const next = await writeMemoryConfig(RUNTIME_DATA_DIR, patch); res.json({ enabled: next.enabled, chatExtractionEnabled: next.chatExtractionEnabled, extraction: maskMemoryExtractionConfig(next.extraction), }); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); // SSE feed of memory mutations. The web settings panel subscribes to // this and re-fetches on every event; toast UIs can listen for // `kind === 'extract'` and surface a small "Memory updated (N new)" // notification. Payload shape: MemoryChangeEvent (see ./memory.ts). // // The same connection also forwards `extraction` events — one per LLM // extraction phase transition — so the settings panel can render a // live "recent extractions" list. We multiplex on a single SSE stream // so the browser opens one connection instead of two. app.get('/api/memory/events', async (_req, res) => { const sse = createSseResponse(res); sse.send('connected', { at: Date.now() }); const onChange = (event) => { sse.send('change', event); }; const onExtraction = (event) => { sse.send('extraction', event); }; memoryEvents.on('change', onChange); memoryEvents.on('extraction', onExtraction); res.on('close', () => { memoryEvents.off('change', onChange); memoryEvents.off('extraction', onExtraction); }); }); // Recent LLM-extraction attempts (newest first; capped server-side). // Surfaces skip reasons, in-flight calls, success counts, and errors // so the settings panel can show "why didn't memory update?" at a // glance instead of leaving the user to guess. app.get('/api/memory/extractions', async (_req, res) => { try { res.json({ extractions: listMemoryExtractions() }); } catch (err) { res.status(500).json({ error: String(err) }); } }); // Drop the entire extraction history. Registered BEFORE the `:id` // catch-all so a literal "/api/memory/extractions" can still be // cleared with `curl -X DELETE`. app.delete('/api/memory/extractions', async (_req, res) => { try { const removed = clearMemoryExtractions(); res.json({ removed }); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); app.delete('/api/memory/extractions/:id', async (req, res) => { try { const removed = removeMemoryExtraction(req.params.id); res.json({ removed }); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); app.post('/api/memory/connectors/suggest', requireLocalDaemonRequest, async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? req.body : {}; const connectorIds = Array.isArray(body.connectorIds) ? body.connectorIds .filter((id) => typeof id === 'string') .map((id) => id.trim()) .filter(Boolean) .slice(0, 12) : undefined; const query = typeof body.query === 'string' ? body.query.trim().slice(0, 240) : ''; const projectId = typeof body.projectId === 'string' && body.projectId.trim() ? body.projectId.trim() : null; const appConfig = await readAppConfig(RUNTIME_DATA_DIR).catch(() => ({})); const chatAgentId = typeof body.chatAgentId === 'string' && body.chatAgentId.trim() ? body.chatAgentId.trim() : typeof appConfig.agentId === 'string' && appConfig.agentId.trim() ? appConfig.agentId.trim() : null; const requestChatModel = typeof body.chatModel === 'string' && body.chatModel.trim() ? body.chatModel.trim() : null; const chatModel = requestChatModel || (chatAgentId && appConfig.agentModels?.[chatAgentId]?.model ? appConfig.agentModels[chatAgentId].model : null); const result = await suggestMemoryFromConnectors(RUNTIME_DATA_DIR, { projectsRoot: PROJECTS_DIR, projectRoot: PROJECT_ROOT, projectId, connectorIds, query, chatAgentId, chatModel, }); res.json(result); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); app.post('/api/memory/connectors/extract', requireLocalDaemonRequest, async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? req.body : {}; const connectorIds = Array.isArray(body.connectorIds) ? body.connectorIds .filter((id) => typeof id === 'string') .map((id) => id.trim()) .filter(Boolean) .slice(0, 12) : undefined; const query = typeof body.query === 'string' ? body.query.trim().slice(0, 240) : ''; const projectId = typeof body.projectId === 'string' && body.projectId.trim() ? body.projectId.trim() : null; const appConfig = await readAppConfig(RUNTIME_DATA_DIR).catch(() => ({})); const chatAgentId = typeof body.chatAgentId === 'string' && body.chatAgentId.trim() ? body.chatAgentId.trim() : typeof appConfig.agentId === 'string' && appConfig.agentId.trim() ? appConfig.agentId.trim() : null; const requestChatModel = typeof body.chatModel === 'string' && body.chatModel.trim() ? body.chatModel.trim() : null; const chatModel = requestChatModel || (chatAgentId && appConfig.agentModels?.[chatAgentId]?.model ? appConfig.agentModels[chatAgentId].model : null); const result = await extractMemoryFromConnectors(RUNTIME_DATA_DIR, { projectsRoot: PROJECTS_DIR, projectRoot: PROJECT_ROOT, projectId, connectorIds, query, chatAgentId, chatModel, }); res.json(result); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); // Imperative extract — used by CLI chats internally and by BYOK / // API-mode chats from the web app, which never reach the chat-run // path on the daemon. Mirrors the two-phase hook the daemon's chat // route applies inline: // // - Pre-turn (only `userMessage` supplied): run the synchronous // heuristic regex pack so explicit "remember: X" / "我是 X" // markers land in memory before the prompt is composed, and the // same turn's assistant reply already reflects them. // - Post-turn (`userMessage` + `assistantMessage` supplied): queue // the LLM extractor in the background — it speaks SSE / // extraction-history on its own and may take several seconds, so // we don't block the HTTP response on it. The heuristic is // skipped on this branch because the caller already ran it // pre-turn; running it twice would double the // `recordHeuristic({...})` rows in the extraction history for // every turn. // // External callers (curl, replay tools) that pass only // `userMessage` keep the legacy behaviour: heuristic-only. app.post('/api/memory/extract', async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? req.body : {}; const userMessage = typeof body.userMessage === 'string' ? body.userMessage : ''; const assistantMessage = typeof body.assistantMessage === 'string' ? body.assistantMessage : ''; const hasAssistant = assistantMessage.trim().length > 0; const memoryConfig = await readMemoryConfig(RUNTIME_DATA_DIR); if (memoryConfig.chatExtractionEnabled === false) { return res.json({ changed: [], attemptedLLM: false }); } const changed = hasAssistant ? [] : await extractFromMessage(RUNTIME_DATA_DIR, userMessage); // BYOK chat config — only forwarded by the web app for API-mode // chats. We strip the surface to the five fields pickProvider() // actually consumes and validate the provider against the four // shapes the extractor speaks; an unknown / missing provider // means "let the legacy chain decide" so a malformed payload // can't override the env / media-config fallbacks. const rawChat = body.chatProvider; let chatProvider = null; if (rawChat && typeof rawChat === 'object') { const provider = rawChat.provider; if ( provider === 'anthropic' || provider === 'openai' || provider === 'azure' || provider === 'google' || provider === 'ollama' ) { chatProvider = { provider, apiKey: typeof rawChat.apiKey === 'string' ? rawChat.apiKey : '', baseUrl: typeof rawChat.baseUrl === 'string' ? rawChat.baseUrl : '', apiVersion: typeof rawChat.apiVersion === 'string' ? rawChat.apiVersion : '', model: typeof rawChat.model === 'string' ? rawChat.model : '', }; } } let attemptedLLM = false; if (userMessage.trim().length > 0 && hasAssistant) { attemptedLLM = true; void import('./memory-llm.js') .then(({ extractWithLLM }) => extractWithLLM( RUNTIME_DATA_DIR, { userMessage, assistantMessage }, { projectRoot: PROJECT_ROOT, chatAgentId: null, chatProvider, }, ), ) .catch((err) => console.warn('[memory-llm] background failed (http extract)', err), ); } res.json({ changed, attemptedLLM }); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); // Composed memory body for the system prompt. Daemon-side chat runs // call `composeMemoryBody()` directly; the web app (BYOK / API mode) // can't import daemon internals, so this endpoint exposes the same // string the daemon would have folded into the system prompt for a // CLI run. `ProjectView.composedSystemPrompt()` calls it before each // BYOK turn and passes the result into `composeSystemPrompt`'s // `memoryBody` field — without this, the Memory tab is a no-op for // BYOK users even though the UI saves model/index/entries for them. app.get('/api/memory/system-prompt', async (_req, res) => { try { const body = await composeMemoryBody(RUNTIME_DATA_DIR); res.json({ body }); } catch (err) { res.status(500).json({ error: String((err && err.message) || err) }); } }); app.get('/api/automation-source-packets', async (req, res) => { try { const limit = typeof req.query.limit === 'string' ? Number(req.query.limit) : undefined; const packets = await listAutomationSourcePackets(RUNTIME_DATA_DIR, { limit }); res.json({ packets }); } catch (err) { res.status(500).json({ error: String((err && err.message) || err) }); } }); app.post('/api/automation-ingestions', async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? req.body : {}; const result = await ingestAutomationSource(RUNTIME_DATA_DIR, body); res.json(result); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); app.get('/api/automation-source-packets/:id', async (req, res) => { try { const packet = await getAutomationSourcePacket(RUNTIME_DATA_DIR, req.params.id); if (!packet) return res.status(404).json({ error: 'automation source packet not found' }); res.json({ packet }); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); app.get('/api/automation-proposals', async (req, res) => { try { const rawStatus = typeof req.query.status === 'string' ? req.query.status : 'all'; const proposals = await listAutomationProposals(RUNTIME_DATA_DIR, { status: rawStatus, }); res.json({ proposals }); } catch (err) { res.status(500).json({ error: String((err && err.message) || err) }); } }); app.post('/api/automation-proposals', async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? req.body : {}; const proposal = await createAutomationProposal(RUNTIME_DATA_DIR, body); res.json({ proposal }); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); app.get('/api/automation-proposals/:id', async (req, res) => { try { const proposal = await getAutomationProposal(RUNTIME_DATA_DIR, req.params.id); if (!proposal) return res.status(404).json({ error: 'automation proposal not found' }); res.json({ proposal }); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); app.post('/api/automation-proposals/:id/apply', async (req, res) => { try { const result = await applyAutomationProposal(RUNTIME_DATA_DIR, req.params.id); res.json(result); } catch (err) { const message = String((err && err.message) || err); const status = message.includes('not found') ? 404 : 400; res.status(status).json({ error: message }); } }); app.post('/api/automation-proposals/:id/reject', async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? req.body : {}; const proposal = await rejectAutomationProposal( RUNTIME_DATA_DIR, req.params.id, typeof body.reason === 'string' ? body.reason : undefined, ); res.json({ proposal }); } catch (err) { const message = String((err && err.message) || err); const status = message.includes('not found') ? 404 : 400; res.status(status).json({ error: message }); } }); app.post('/api/memory', async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? req.body : {}; const entry = await upsertMemoryEntry(RUNTIME_DATA_DIR, body); res.json({ entry }); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); app.get('/api/memory/:id', async (req, res) => { try { const entry = await readMemoryEntry(RUNTIME_DATA_DIR, req.params.id); if (!entry) return res.status(404).json({ error: 'memory not found' }); res.json({ entry }); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); app.put('/api/memory/:id', async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? req.body : {}; const entry = await upsertMemoryEntry(RUNTIME_DATA_DIR, { ...body, id: req.params.id, }); res.json({ entry }); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); app.delete('/api/memory/:id', async (req, res) => { try { await deleteMemoryEntry(RUNTIME_DATA_DIR, req.params.id); res.json({ ok: true }); } catch (err) { res.status(400).json({ error: String((err && err.message) || err) }); } }); // Reconcile follow-up — the inline POST /api/projects body that lived // on garnet (with baseDir privilege check, linkedDirs validation, // template snapshot seeding, plugin snapshot resolution with default // scenario fallback) is intentionally dropped here. main moved project // route registration into `./project-routes.js` via PR #1043, so the // simple project-create surface is wired through `registerProjectRoutes` // further down. Plugin-snapshot-resolution / default-scenario-fallback // from garnet need to be re-integrated into project-routes.ts as a // follow-up — see reconcile decision log. // (legacy POST /api/projects body deleted — see registerProjectRoutes below.) const analyticsService = createAnalyticsService({ dataDir: RUNTIME_DATA_DIR }); const design = { runs: createChatRunService({ createSseResponse, createSseErrorPayload }), analytics: analyticsService, getAppVersion: () => cachedAppVersion?.version ?? '0.0.0', readAnalyticsContext, }; // PostHog runtime config — gated on BOTH a server-side key (POSTHOG_KEY) // and the user's opt-in metrics consent (Privacy → "Share usage data"). // The web bundle short-circuits when enabled=false so opt-out behaviour // is instant after the user toggles metrics off and reloads. app.get('/api/analytics/config', async (_req, res) => { const baseline = readPublicConfigResponse(); if (!baseline.enabled) { res.json(baseline); return; } try { const appCfg = await readAppConfig(RUNTIME_DATA_DIR); const consentGranted = appCfg.telemetry?.metrics === true; if (!consentGranted) { res.json({ enabled: false, key: null, host: null }); return; } // Echo the installationId so the web client uses the same anonymous // id PostHog already saw on prior runs (and that Langfuse uses too). const installationId = typeof appCfg.installationId === 'string' && appCfg.installationId ? appCfg.installationId : null; res.json({ ...baseline, installationId }); } catch { // If the config file is unreadable, fail closed — no events. res.json({ enabled: false, key: null, host: null }); } }); // Tracks runs whose completion has already been forwarded to Langfuse so // repeated message updates only emit one trace per run. const reportedRuns = new Set(); // App-version snapshot read once at server start for Langfuse trace metadata. let cachedAppVersion = null; void (async () => { try { cachedAppVersion = await readCurrentAppVersionInfo(); } catch { // Telemetry is best-effort; appVersion is omitted when unavailable. } })(); const reportFinalizedMessage = createFinalizedMessageTelemetryReporter({ design, db, dataDir: RUNTIME_DATA_DIR, reportedRuns, getAppVersion: () => cachedAppVersion, }); // DNS-aware wrapper. The sync `validateBaseUrl` only inspects the literal // hostname string, so a public DNS name pointing at an internal address // (`internal.example.com → 10.0.0.5`) still passes. We delegate to // `validateBaseUrlResolved` here so every proxy and finalize handler runs // the same resolved-IP check before issuing the upstream request. const validateExternalApiBaseUrl = (baseUrl) => validateBaseUrlResolved(baseUrl); const resolvedPortRef = { get current() { return resolvedPort; }, }; const daemonUrlRef = { get current() { return daemonUrl; }, }; const httpDeps = { sendApiError, sendMulterError, sendLiveArtifactRouteError, createSseResponse, requireLocalDaemonRequest, isLocalSameOrigin, resolvedPortRef, }; const pathDeps = { PROJECT_ROOT, PROJECTS_DIR, ARTIFACTS_DIR, RUNTIME_DATA_DIR, RUNTIME_DATA_DIR_CANONICAL, DESIGN_SYSTEMS_DIR, USER_DESIGN_SYSTEMS_DIR, DESIGN_TEMPLATES_DIR, USER_DESIGN_TEMPLATES_DIR, SKILLS_DIR, USER_SKILLS_DIR, PROMPT_TEMPLATES_DIR, BUNDLED_PETS_DIR, OD_BIN, }; const nodeDeps = { fs, path }; const idDeps = { randomId, randomUUID }; const uploadDeps = { upload, importUpload, handleProjectUpload }; const projectStoreDeps = { getProject, insertProject, updateProject, dbDeleteProject, removeProjectDir, validateLinkedDirs, }; const projectFileDeps = { ensureProject, listFiles, searchProjectFiles, readProjectFile, resolveProjectDir, resolveProjectFilePath, parseByteRange, renameProjectFile, deleteProjectFile, writeProjectFile, sanitizeName, listTabs, setTabs, }; const conversationDeps = { insertConversation, getConversation, listConversations, updateConversation, deleteConversation, listMessages, upsertMessage, listPreviewComments, upsertPreviewComment, updatePreviewCommentStatus, deletePreviewComment, }; const templateDeps = { getTemplate, listTemplates, deleteTemplate, insertTemplate, findTemplateByNameAndProject, updateTemplate }; const projectStatusDeps = { listLatestProjectRunStatuses, listProjectsAwaitingInput, normalizeProjectDisplayStatus, composeProjectDisplayStatus, listProjects, }; const projectEventDeps = { subscribeFileEvents, activeProjectEventSinks }; const importDeps = { importClaudeDesignZip, projectDir, detectEntryFile }; const projectExportDeps = { buildProjectArchive, buildBatchArchive, buildDesktopPdfExportInput, desktopPdfExporter, daemonUrlRef, sanitizeArchiveFilename, }; const artifactDeps = { sanitizeSlug, lintArtifact, renderFindingsForAgent, validateArtifactManifestInput, }; const deployDeps = { VERCEL_PROVIDER_ID, CLOUDFLARE_PAGES_PROVIDER_ID, isDeployProviderId, publicDeployConfigForProvider, readDeployConfig, writeDeployConfig, listCloudflarePagesZones, DeployError, listDeployments, publicDeployments, getDeployment, getDeploymentById, buildDeployFileSet, cloudflarePagesProjectNameForDeploy, cloudflarePagesProjectNameFromDeployment, checkCloudflarePagesDeploymentLinks, checkDeploymentUrl, deployToCloudflarePages, deployToVercel, upsertDeployment, publicDeployment, cloudflarePagesDeploymentMetadata, prepareDeployPreflight, }; const mediaDeps = { MEDIA_PROVIDERS, IMAGE_MODELS, VIDEO_MODELS, AUDIO_MODELS_BY_KIND, MEDIA_ASPECTS, VIDEO_LENGTHS_SEC, AUDIO_DURATIONS_SEC, readMaskedConfig, writeConfig, generateMedia, mediaTasks, createMediaTask: (taskId, projectId, info) => createMediaTask(db, taskId, projectId, info), persistMediaTask: (task) => persistMediaTask(db, task), appendTaskProgress: (task, line) => appendTaskProgress(db, task, line), notifyTaskWaiters: (task) => notifyTaskWaiters(db, task), getLiveMediaTask: (taskId) => getLiveMediaTask(db, taskId), mediaTaskSnapshot, listMediaTasksByProject, listElevenLabsVoiceOptions, }; const appConfigDeps = { readAppConfig, writeAppConfig }; const orbitDeps = { orbitService }; const nativeDialogDeps = { openNativeFolderDialog }; const researchDeps = { searchResearch, ResearchError }; const liveArtifactDeps = { createLiveArtifact, listLiveArtifacts, updateLiveArtifact, refreshLiveArtifact, emitLiveArtifactEvent, emitLiveArtifactRefreshEvent, readLiveArtifactCode, setLiveArtifactCodeHeaders, ensureLiveArtifactPreview, setLiveArtifactPreviewHeaders, getLiveArtifact, listLiveArtifactRefreshLogEntries, deleteLiveArtifact, }; const authDeps = { authorizeToolRequest, consumedImportNonces, desktopAuthSecret: getDesktopAuthSecret, isDesktopAuthGateActive, pruneExpiredImportNonces, requestProjectOverride, requestRunOverride, verifyDesktopImportToken, }; const finalizeDeps = { defaultBaseUrlForFinalizeProtocol, finalizeDesignPackage, FinalizePackageLockedError, FinalizeUpstreamError, isFinalizeProviderProtocol, redactSecrets, }; const handoffDeps = { synthesizeHandoffPrompt, FinalizeUpstreamError, TranscriptExportLockedError, EmptyTranscriptError, redactSecrets, }; const validationDeps = { isSafeId, validateExternalApiBaseUrl, validateBaseUrl, validateProjectDesignSystemId }; const agentDeps = { listProviderModels, testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, }; const critiqueDeps = { handleCritiqueArtifact, handleCritiqueInterrupt, critiqueArtifactsRoot: CRITIQUE_ARTIFACTS_DIR, critiqueResponseCapBytes: critiqueCfg.parserMaxBlockBytes, critiqueRunRegistry, }; // External services registerMcpRoutes(app, { http: httpDeps, paths: pathDeps, mcp: { pendingAuth: mcpPendingAuth, daemonUrlRef }, }); registerXaiRoutes(app, { http: httpDeps, paths: pathDeps, }); // Project workspace registerActiveContextRoutes(app, { db, http: httpDeps, projectStore: projectStoreDeps, }); registerHostToolsRoutes(app, { db, http: httpDeps, paths: pathDeps, projectStore: projectStoreDeps, projectFiles: projectFileDeps, }); registerProjectRoutes(app, { db, design, http: httpDeps, paths: pathDeps, projectStore: projectStoreDeps, projectFiles: projectFileDeps, conversations: conversationDeps, templates: templateDeps, status: projectStatusDeps, events: projectEventDeps, ids: idDeps, telemetry: { reportFinalizedMessage }, validation: validationDeps, }); registerImportRoutes(app, { db, http: httpDeps, uploads: uploadDeps, node: nodeDeps, ids: idDeps, paths: pathDeps, imports: importDeps, auth: authDeps, projectStore: projectStoreDeps, conversations: conversationDeps, projectFiles: projectFileDeps, validation: validationDeps, }); // Resource catalog registerStaticResourceRoutes(app, { http: httpDeps, paths: pathDeps, resources: { listAllSkills, listAllDesignTemplates, listAllSkillLikeEntries, listAllDesignSystems, mimeFor, }, }); registerProjectArtifactRoutes(app, { http: httpDeps, uploads: uploadDeps, paths: pathDeps, node: nodeDeps, artifacts: artifactDeps, }); registerLiveArtifactRoutes(app, { db, http: httpDeps, paths: pathDeps, auth: authDeps, liveArtifacts: liveArtifactDeps, projectStore: projectStoreDeps, }); registerDesignSystemToolRoutes(app, { auth: authDeps, http: httpDeps, paths: pathDeps, projects: { getProject }, }); app.use('/artifacts', express.static(ARTIFACTS_DIR)); registerDeployRoutes(app, { db, http: httpDeps, paths: pathDeps, ids: idDeps, deploy: deployDeps, projectStore: projectStoreDeps, }); registerFinalizeRoutes(app, { db, http: httpDeps, paths: pathDeps, projectStore: projectStoreDeps, validation: validationDeps, finalize: finalizeDeps, }); registerHandoffRoutes(app, { db, http: httpDeps, paths: pathDeps, projectStore: projectStoreDeps, conversations: conversationDeps, validation: validationDeps, handoff: handoffDeps, }); registerDeploymentCheckRoutes(app, { db, http: httpDeps, deploy: deployDeps }); app.use('/frames', express.static(FRAMES_DIR)); registerProjectExportRoutes(app, { db, http: httpDeps, paths: pathDeps, projectStore: projectStoreDeps, exports: projectExportDeps, projectFiles: projectFileDeps, validation: validationDeps, }); registerProjectFileRoutes(app, { db, http: httpDeps, paths: pathDeps, uploads: uploadDeps, node: nodeDeps, projectStore: projectStoreDeps, projectFiles: projectFileDeps, documents: { buildDocumentPreview }, artifacts: artifactDeps, }); registerMediaRoutes(app, { db, http: httpDeps, paths: pathDeps, ids: idDeps, media: mediaDeps, appConfig: appConfigDeps, orbit: orbitDeps, nativeDialogs: nativeDialogDeps, projectStore: projectStoreDeps, projectFiles: projectFileDeps, conversations: conversationDeps, research: researchDeps, }); app.delete('/api/projects/:id', async (req, res) => { try { dbDeleteProject(db, req.params.id); await removeProjectDir(PROJECTS_DIR, req.params.id).catch(() => {}); /** @type {import('@open-design/contracts').OkResponse} */ const body = { ok: true }; res.json(body); } catch (err) { sendApiError(res, 400, 'BAD_REQUEST', String(err)); } }); // SSE stream of file-changed events for a project. Drives preview live-reload. // Receipt of a `file-changed` event triggers a file-list refresh, which // propagates new mtimes through to FileViewer iframes (the URL-load // `?v=${mtime}` cache-bust from PR #384 then reloads the iframe automatically). // Subscribers come and go as users open/close project tabs; the underlying // chokidar watcher is refcounted in project-watchers.ts so we never hold // descriptors for projects no UI is looking at. app.get('/api/projects/:id/events', (req, res) => { if (!getProject(db, req.params.id)) { return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); } let sub; try { const sse = createSseResponse(res); const projectEventSink = (payload) => { sse.send(payload.type, payload); }; let sinks = activeProjectEventSinks.get(req.params.id); if (!sinks) { sinks = new Set(); activeProjectEventSinks.set(req.params.id, sinks); } sinks.add(projectEventSink); const watchProject = getProject(db, req.params.id); sub = subscribeFileEvents(PROJECTS_DIR, req.params.id, (evt) => { sse.send('file-changed', evt); }, { metadata: watchProject?.metadata }); sub.ready.then(() => sse.send('ready', { projectId: req.params.id })).catch(() => {}); const cleanup = () => { if (sub) { const { unsubscribe } = sub; sub = null; Promise.resolve(unsubscribe()).catch(() => {}); } const currentSinks = activeProjectEventSinks.get(req.params.id); currentSinks?.delete(projectEventSink); if (currentSinks?.size === 0) activeProjectEventSinks.delete(req.params.id); }; res.on('close', cleanup); res.on('finish', cleanup); } catch (err) { if (sub) Promise.resolve(sub.unsubscribe()).catch(() => {}); if (!res.headersSent) sendApiError(res, 400, 'BAD_REQUEST', String(err?.message || err)); } }); // ---- Conversations -------------------------------------------------------- app.get('/api/projects/:id/conversations', (req, res) => { if (!getProject(db, req.params.id)) { return res.status(404).json({ error: 'project not found' }); } res.json({ conversations: listConversations(db, req.params.id) }); }); app.post('/api/projects/:id/conversations', (req, res) => { if (!getProject(db, req.params.id)) { return res.status(404).json({ error: 'project not found' }); } const { title } = req.body || {}; const now = Date.now(); const conv = insertConversation(db, { id: randomId(), projectId: req.params.id, title: typeof title === 'string' ? title.trim() || null : null, createdAt: now, updatedAt: now, }); res.json({ conversation: conv }); }); app.patch('/api/projects/:id/conversations/:cid', (req, res) => { const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'not found' }); } const updated = updateConversation(db, req.params.cid, req.body || {}); res.json({ conversation: updated }); }); app.delete('/api/projects/:id/conversations/:cid', (req, res) => { const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'not found' }); } deleteConversation(db, req.params.cid); res.json({ ok: true }); }); // ---- Messages ------------------------------------------------------------- app.get('/api/projects/:id/conversations/:cid/messages', (req, res) => { const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'conversation not found' }); } res.json({ messages: listMessages(db, req.params.cid) }); }); app.put('/api/projects/:id/conversations/:cid/messages/:mid', (req, res) => { const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'conversation not found' }); } const m = req.body || {}; if (m.id && m.id !== req.params.mid) { return res.status(400).json({ error: 'id mismatch' }); } const saved = upsertMessage(db, req.params.cid, { ...m, id: req.params.mid, }); // Bump the parent project's updatedAt so the project list re-orders. updateProject(db, req.params.id, {}); res.json({ message: saved }); }); // ---- Preview comments ---------------------------------------------------- app.get('/api/projects/:id/conversations/:cid/comments', (req, res) => { const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'conversation not found' }); } res.json({ comments: listPreviewComments(db, req.params.id, req.params.cid), }); }); app.post('/api/projects/:id/conversations/:cid/comments', (req, res) => { const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'conversation not found' }); } try { const comment = upsertPreviewComment( db, req.params.id, req.params.cid, req.body || {}, ); updateProject(db, req.params.id, {}); res.json({ comment }); } catch (err) { res.status(400).json({ error: String(err?.message || err) }); } }); app.patch( '/api/projects/:id/conversations/:cid/comments/:commentId', (req, res) => { const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'conversation not found' }); } try { const comment = updatePreviewCommentStatus( db, req.params.id, req.params.cid, req.params.commentId, req.body?.status, ); if (!comment) return res.status(404).json({ error: 'comment not found' }); updateProject(db, req.params.id, {}); res.json({ comment }); } catch (err) { res.status(400).json({ error: String(err?.message || err) }); } }, ); app.delete( '/api/projects/:id/conversations/:cid/comments/:commentId', (req, res) => { const conv = getConversation(db, req.params.cid); if (!conv || conv.projectId !== req.params.id) { return res.status(404).json({ error: 'conversation not found' }); } const ok = deletePreviewComment( db, req.params.id, req.params.cid, req.params.commentId, ); if (!ok) return res.status(404).json({ error: 'comment not found' }); updateProject(db, req.params.id, {}); res.json({ ok: true }); }, ); // ---- Tabs ----------------------------------------------------------------- app.get('/api/projects/:id/tabs', (req, res) => { if (!getProject(db, req.params.id)) { return res.status(404).json({ error: 'project not found' }); } res.json(listTabs(db, req.params.id)); }); app.put('/api/projects/:id/tabs', (req, res) => { if (!getProject(db, req.params.id)) { return res.status(404).json({ error: 'project not found' }); } const { tabs = [], active = null } = req.body || {}; if (!Array.isArray(tabs) || !tabs.every((t) => typeof t === 'string')) { return res.status(400).json({ error: 'tabs must be string[]' }); } const result = setTabs( db, req.params.id, tabs, typeof active === 'string' ? active : null, ); res.json(result); }); // ---- Templates ---------------------------------------------------------- // User-saved snapshots of a project's HTML files. Surfaced in the // "From template" tab of the new-project panel so a user can spin up // a fresh project pre-seeded with another project's design as a // starting point. Created via the project's Share menu (snapshots // every .html file in the project folder at the moment of save). app.get('/api/templates', (_req, res) => { res.json({ templates: listTemplates(db) }); }); app.get('/api/templates/:id', (req, res) => { const t = getTemplate(db, req.params.id); if (!t) return res.status(404).json({ error: 'not found' }); res.json({ template: t }); }); app.post('/api/templates', async (req, res) => { try { const { name, description, sourceProjectId } = req.body || {}; if (typeof name !== 'string' || !name.trim()) { return res.status(400).json({ error: 'name required' }); } if (typeof sourceProjectId !== 'string') { return res.status(400).json({ error: 'sourceProjectId required' }); } const sourceProject = getProject(db, sourceProjectId); if (!sourceProject) { return res.status(404).json({ error: 'source project not found' }); } // Snapshot every HTML / sketch / text file in the source project. // We deliberately skip binary uploads — templates are about the // generated design, not the user's reference imagery. const files = await listFiles(PROJECTS_DIR, sourceProjectId, { metadata: sourceProject.metadata, }); const snapshot = []; for (const f of files) { if (f.kind !== 'html' && f.kind !== 'text' && f.kind !== 'code') continue; const entry = await readProjectFile( PROJECTS_DIR, sourceProjectId, f.name, sourceProject.metadata, ); if (entry && Buffer.isBuffer(entry.buffer)) { snapshot.push({ name: f.name, content: entry.buffer.toString('utf8'), }); } } const t = insertTemplate(db, { id: randomId(), name: name.trim(), description: typeof description === 'string' ? description : null, sourceProjectId, files: snapshot, createdAt: Date.now(), }); res.json({ template: t }); } catch (err) { res.status(400).json({ error: String(err) }); } }); app.delete('/api/templates/:id', (req, res) => { deleteTemplate(db, req.params.id); res.json({ ok: true }); }); app.get('/api/agents', async (_req, res) => { try { const config = await readAppConfig(RUNTIME_DATA_DIR); const list = await detectAgents(config.agentCliEnv ?? {}); res.json({ agents: list }); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.get('/api/skills', async (_req, res) => { try { const skills = await listAllSkills(); // Strip full body + on-disk dir from the listing — frontend fetches the // body via /api/skills/:id when needed (keeps the listing payload small). res.json({ skills: skills.map(({ body, dir: _dir, ...rest }) => ({ ...rest, hasBody: typeof body === 'string' && body.length > 0, })), }); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.get('/api/skills/:id', async (req, res) => { try { const skills = await listAllSkills(); const skill = findSkillById(skills, req.params.id); if (!skill) return res.status(404).json({ error: 'skill not found' }); const { dir: _dir, ...serializable } = skill; res.json(serializable); } catch (err) { res.status(500).json({ error: String(err) }); } }); // Codex hatch-pet registry — pets packaged by the upstream `hatch-pet` // skill under `${CODEX_HOME:-$HOME/.codex}/pets/`. Surfaced so the web // pet settings can offer one-click adoption of recently-hatched pets. app.get('/api/codex-pets', async (_req, res) => { try { const result = await listCodexPets({ baseUrl: '', bundledRoot: BUNDLED_PETS_DIR, }); res.json(result); } catch (err) { res.status(500).json({ error: String(err) }); } }); // One-click community sync. Hits the Codex Pet Share + j20 Hatchery // catalogs and drops every pet into `${CODEX_HOME:-$HOME/.codex}/pets/` // so `GET /api/codex-pets` (and the web Pet settings) pick them up // immediately. The body is intentionally tiny — we keep the heavier // tuning knobs (`--limit`, `--concurrency`) on the CLI script and // only surface `force` + `source` here. app.post('/api/codex-pets/sync', async (req, res) => { try { const body = req.body && typeof req.body === 'object' ? req.body : {}; const sourceRaw = typeof body.source === 'string' ? body.source : 'all'; const source = sourceRaw === 'petshare' || sourceRaw === 'hatchery' ? sourceRaw : 'all'; const result = await syncCommunityPets({ source, force: Boolean(body.force), }); res.json(result); } catch (err) { res.status(500).json({ error: String((err && err.message) || err) }); } }); app.get('/api/codex-pets/:id/spritesheet', async (req, res) => { try { const sheet = await readCodexPetSpritesheet(req.params.id, { bundledRoot: BUNDLED_PETS_DIR, }); if (!sheet) { return res .status(404) .type('text/plain') .send('codex pet spritesheet not found'); } const mime = sheet.ext === 'webp' ? 'image/webp' : sheet.ext === 'gif' ? 'image/gif' : 'image/png'; res.type(mime); // Same-origin callers (the web app proxies `/api/*` through to // the daemon, so PetSettings adoption fetches arrive same-origin) // do not need any CORS header here. We only echo // `Access-Control-Allow-Origin` for sandboxed iframes / data: // URIs (Origin: null) which need it to draw the bytes onto a // canvas without tainting. Local pet bytes should not be exposed // to arbitrary third-party origins via a wildcard ACAO. if (req.headers.origin === 'null') { res.setHeader('Access-Control-Allow-Origin', 'null'); } res.setHeader('Cache-Control', 'no-store'); res.sendFile(sheet.absPath); } catch (err) { res.status(500).type('text/plain').send(String(err)); } }); app.get('/api/design-systems', async (_req, res) => { try { const systems = await listAllDesignSystems(); res.json({ designSystems: systems.map(({ body, ...rest }) => rest), }); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.post('/api/design-systems', async (req, res) => { try { const created = await createUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.body || {}); res.status(201).json({ ...created, designSystem: created }); } catch (err) { res.status(400).json({ error: String(err) }); } }); app.post('/api/design-systems/generation-jobs', async (req, res) => { try { const job = designSystemGenerationJobs.start(req.body || {}); res.status(202).json({ job }); } catch (err) { res.status(400).json({ error: String(err) }); } }); app.get('/api/design-systems/generation-jobs/:jobId', async (req, res) => { try { const job = designSystemGenerationJobs.get(req.params.jobId); if (!job) { return res.status(404).json({ error: 'design system generation job not found' }); } res.json({ job }); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.post('/api/design-systems/:id/revision-jobs', async (req, res) => { try { const feedback = typeof req.body?.feedback === 'string' ? req.body.feedback : ''; if (!feedback.trim()) return res.status(400).json({ error: 'feedback is required' }); const job = designSystemGenerationJobs.revise({ designSystemId: req.params.id, feedback, sectionTitle: typeof req.body?.sectionTitle === 'string' ? req.body.sectionTitle : undefined, body: typeof req.body?.body === 'string' ? req.body.body : undefined, }); res.status(202).json({ job }); } catch (err) { res.status(400).json({ error: String(err) }); } }); app.get('/api/design-systems/:id/revisions', async (req, res) => { try { const revisions = await listUserDesignSystemRevisions( USER_DESIGN_SYSTEMS_DIR, req.params.id, ); if (!revisions) { return res.status(404).json({ error: 'editable design system not found' }); } res.json({ revisions }); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.patch('/api/design-systems/:id/revisions/:revisionId', async (req, res) => { try { const status = typeof req.body?.status === 'string' ? req.body.status : ''; if (status !== 'accepted' && status !== 'rejected') { return res.status(400).json({ error: 'status must be accepted or rejected' }); } const revision = await updateUserDesignSystemRevisionStatus( USER_DESIGN_SYSTEMS_DIR, req.params.id, req.params.revisionId, status, ); if (!revision) { return res.status(404).json({ error: 'design system revision not found' }); } res.json({ revision }); } catch (err) { res.status(400).json({ error: String(err) }); } }); app.get('/api/design-systems/:id', async (req, res) => { try { const systems = await listAllDesignSystems(); const summary = systems.find((s) => s.id === req.params.id); const projectBody = await readDesignSystemWorkspaceTextFile(db, summary, 'DESIGN.md'); const body = projectBody ?? await readAvailableDesignSystem(req.params.id); if (body === null || !summary) return res.status(404).json({ error: 'design system not found' }); const packageInfo = await readAvailableDesignSystemPackageInfo(req.params.id); const detail = { ...summary, body, ...(packageInfo ? { packageInfo } : {}) }; res.json({ ...detail, designSystem: detail }); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.post('/api/design-systems/:id/workspace', async (req, res) => { try { const workspace = await ensureUserDesignSystemWorkspaceProject(db, req.params.id); if (!workspace) { return res.status(404).json({ error: 'editable design system not found' }); } res.status(201).json(workspace); } catch (err) { res.status(400).json({ error: String(err) }); } }); app.get('/api/design-systems/:id/files', async (req, res) => { try { const files = await listUserDesignSystemFiles(USER_DESIGN_SYSTEMS_DIR, req.params.id); if (!files) { return res.status(404).json({ error: 'editable design system not found' }); } res.json({ files }); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.get('/api/design-systems/:id/file', async (req, res) => { try { const requestedPath = typeof req.query.path === 'string' ? req.query.path : ''; const file = await readUserDesignSystemFile( USER_DESIGN_SYSTEMS_DIR, req.params.id, requestedPath, ); if (!file) return res.status(404).json({ error: 'design system file not found' }); res.json({ file }); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.patch('/api/design-systems/:id', async (req, res) => { try { const updated = await updateUserDesignSystem( USER_DESIGN_SYSTEMS_DIR, req.params.id, req.body || {}, ); if (!updated) { return res.status(404).json({ error: 'editable design system not found' }); } res.json({ ...updated, designSystem: updated }); } catch (err) { res.status(400).json({ error: String(err) }); } }); app.delete('/api/design-systems/:id', async (req, res) => { try { const ok = await deleteUserDesignSystem(USER_DESIGN_SYSTEMS_DIR, req.params.id); if (!ok) { return res.status(404).json({ error: 'editable design system not found' }); } res.status(204).end(); } catch (err) { res.status(500).json({ error: String(err) }); } }); // Plugin-system HTTP surface. Spec §11.5. Phase 1 wires the minimum set // needed for the §12.5 walkthrough: list/get installed plugins, install // (SSE), uninstall, apply (returns ApplyResult + snapshotId), atom catalog, // and snapshot fetch by id (used by run replay tooling). app.get('/api/plugins', async (_req, res) => { try { const plugins = listInstalledPlugins(db); res.json({ plugins }); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.get('/api/plugins/:id', async (req, res) => { try { const plugin = getInstalledPlugin(db, req.params.id); if (!plugin) return res.status(404).json({ error: 'plugin not found' }); res.json(plugin); } catch (err) { res.status(500).json({ error: String(err) }); } }); async function finishUploadedPluginInstall(stagedFolder, source) { const warnings = []; const log = []; let plugin = null; let message = 'Install finished.'; try { const pluginRoot = await findUploadedPluginRoot(stagedFolder); for await (const ev of installFromLocalFolder(db, { source, roots: PLUGIN_REGISTRY_ROOTS, _stagedFolder: pluginRoot, _stagedSourceKind: 'user', lockfilePath: PLUGIN_LOCKFILE_PATH, })) { if (ev.message) log.push(ev.message); if (Array.isArray(ev.warnings)) warnings.splice(0, warnings.length, ...ev.warnings); if (ev.kind === 'success') { plugin = ev.plugin; message = `Installed ${ev.plugin.title}.`; break; } if (ev.kind === 'error') { message = ev.message; break; } } return { ok: Boolean(plugin), plugin, warnings, message, log }; } finally { await fs.promises.rm(stagedFolder, { recursive: true, force: true }).catch(() => undefined); } } async function findUploadedPluginRoot(stagedFolder) { if (await folderLooksLikePlugin(stagedFolder)) return stagedFolder; const entries = await fs.promises.readdir(stagedFolder, { withFileTypes: true }); const dirs = entries.filter((entry) => entry.isDirectory()); const files = entries.filter((entry) => entry.isFile()); if (files.length === 0 && dirs.length === 1) { const nested = path.join(stagedFolder, dirs[0].name); if (await folderLooksLikePlugin(nested)) return nested; } return stagedFolder; } async function folderLooksLikePlugin(folder) { const names = ['open-design.json', 'SKILL.md', path.join('.claude-plugin', 'plugin.json')]; for (const name of names) { if (fs.existsSync(path.join(folder, name))) return true; } return false; } function safeUploadRelativePath(input) { const value = String(input || '').replace(/\\/g, '/'); if (!value || value.includes('\0') || value.startsWith('/') || /^[A-Za-z]:\//.test(value)) { throw new Error('invalid upload path'); } const parts = value.split('/').filter(Boolean); if (parts.length === 0 || parts.some((part) => part === '.' || part === '..')) { throw new Error(`unsafe upload path: ${value}`); } return parts.join(path.sep); } async function extractPluginZipToFolder(buffer, stagedFolder) { if (buffer.length > PLUGIN_UPLOAD_MAX_BYTES) { throw new Error('zip file too large'); } const zip = await JSZip.loadAsync(buffer); let totalBytes = 0; const entries = Object.values(zip.files); if (entries.length === 0) throw new Error('zip contains no files'); for (const entry of entries) { if (entry.dir) continue; const rel = safeUploadRelativePath(entry.name); const unixMode = typeof entry.unixPermissions === 'number' ? entry.unixPermissions : 0; if ((unixMode & 0o170000) === 0o120000) { throw new Error(`zip entry is a symbolic link: ${entry.name}`); } const content = await entry.async('nodebuffer'); totalBytes += content.length; if (totalBytes > PLUGIN_UPLOAD_MAX_BYTES) { throw new Error('zip extracted size exceeds 50 MiB'); } const dest = path.join(stagedFolder, rel); await fs.promises.mkdir(path.dirname(dest), { recursive: true }); await fs.promises.writeFile(dest, content); } } app.post('/api/plugins/upload-zip', (req, res) => { pluginUpload.single('file')(req, res, async (err) => { if (err) return sendMulterError(res, err); try { const file = req.file; if (!file || !file.buffer) { return res.status(400).json({ error: 'file is required' }); } const stagedFolder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'od-plugin-zip-')); await extractPluginZipToFolder(file.buffer, stagedFolder); const result = await finishUploadedPluginInstall( stagedFolder, `upload:zip:${decodeMultipartFilename(file.originalname || 'plugin.zip')}`, ); res.status(result.ok ? 200 : 400).json(result); } catch (uploadErr) { res.status(400).json({ ok: false, warnings: [], message: String(uploadErr?.message || uploadErr), log: [], }); } }); }); app.post('/api/plugins/upload-folder', (req, res) => { pluginUpload.array('files', 500)(req, res, async (err) => { if (err) return sendMulterError(res, err); const stagedFolder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'od-plugin-folder-')); try { const files = Array.isArray(req.files) ? req.files : []; if (files.length === 0) { await fs.promises.rm(stagedFolder, { recursive: true, force: true }).catch(() => undefined); return res.status(400).json({ error: 'files are required' }); } const rawPaths = req.body?.paths; const paths = Array.isArray(rawPaths) ? rawPaths : rawPaths ? [rawPaths] : []; let totalBytes = 0; for (let i = 0; i < files.length; i += 1) { const file = files[i]; totalBytes += file.buffer.length; if (totalBytes > PLUGIN_UPLOAD_MAX_BYTES) { throw new Error('folder upload exceeds 50 MiB'); } const rel = safeUploadRelativePath(paths[i] || file.originalname); const dest = path.join(stagedFolder, rel); await fs.promises.mkdir(path.dirname(dest), { recursive: true }); await fs.promises.writeFile(dest, file.buffer); } const result = await finishUploadedPluginInstall(stagedFolder, 'upload:folder'); res.status(result.ok ? 200 : 400).json(result); } catch (uploadErr) { await fs.promises.rm(stagedFolder, { recursive: true, force: true }).catch(() => undefined); res.status(400).json({ ok: false, warnings: [], message: String(uploadErr?.message || uploadErr), log: [], }); } }); }); app.post('/api/plugins/install', async (req, res) => { const body = req.body && typeof req.body === 'object' ? req.body : {}; let source = typeof body.source === 'string' ? body.source : ''; let marketplaceResolution: { marketplaceId: string; marketplaceTrust: 'official' | 'trusted' | 'restricted'; pluginName: string; pluginVersion: string; source: string; ref?: string; manifestDigest?: string; archiveIntegrity?: string; } | null = null; if (!source) { return res.status(400).json({ error: 'source is required' }); } // Plan §3.A6: accept local folder, github:owner/repo[@ref][/subpath], // and https://*.tar.gz / *.tgz sources. Plan §3.F3: also accept a // bare plugin name and resolve it through the configured marketplaces. // Other shapes are 400 so the error surface is clear. const looksAbsolute = source.startsWith('/') || source.startsWith('./') || source.startsWith('~'); const looksGithub = source.startsWith('github:'); const looksHttps = /^https:\/\//i.test(source); if (!looksAbsolute && !looksGithub && !looksHttps) { // Treat the source as a plugin name and look it up in the // marketplace registry. Match resolution returns the canonical // source (github:… / https://…) so the installer can replay // the same byte path that would happen if the user copy-pasted // the source manually. const { resolvePluginInMarketplaces } = await import('./plugins/marketplaces.js'); let lookupName = source; const lockfile = await readPluginLockfile(PLUGIN_LOCKFILE_PATH); const locked = lockfile.plugins[source]; if (locked?.version && !source.includes('@')) { lookupName = `${source}@${locked.version}`; } const resolved = resolvePluginInMarketplaces(db, lookupName); if (!resolved) { return res.status(404).json({ error: { code: 'plugin-not-found', message: `No marketplace plugin named "${source}". Add a marketplace via 'od marketplace add ' or pass a github: / https:// / local source.`, data: { name: source }, }, }); } marketplaceResolution = resolved; source = resolved.source; } res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders?.(); const writeEvent = (event: string, data: unknown) => { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); }; try { for await (const ev of installPlugin(db, { source, roots: PLUGIN_REGISTRY_ROOTS, sourceMarketplaceId: marketplaceResolution?.marketplaceId, sourceMarketplaceEntryName: marketplaceResolution?.pluginName, sourceMarketplaceEntryVersion: marketplaceResolution?.pluginVersion, marketplaceTrust: marketplaceResolution?.marketplaceTrust, resolvedSource: marketplaceResolution?.source, resolvedRef: marketplaceResolution?.ref, manifestDigest: marketplaceResolution?.manifestDigest, archiveIntegrity: marketplaceResolution?.archiveIntegrity, lockfilePath: PLUGIN_LOCKFILE_PATH, })) { writeEvent(ev.kind, ev); if (ev.kind === 'success' || ev.kind === 'error') break; } } catch (err) { writeEvent('error', { kind: 'error', message: String(err), warnings: [] }); } finally { res.end(); } }); app.post('/api/plugins/:id/uninstall', async (req, res) => { try { const result = await uninstallPlugin(db, req.params.id, PLUGIN_REGISTRY_ROOTS); if (!result.ok && !result.removedFolder) { return res.status(404).json({ error: 'plugin not found', warning: result.warning }); } res.json(result); } catch (err) { res.status(500).json({ error: String(err) }); } }); // Plan §3.Z2 — `od plugin upgrade ` re-installs a plugin from // its recorded source. Streams the same SSE shape as // POST /api/plugins/install so CLIs and the web composer reuse // the existing event handler. // // Rejected for source_kind='bundled': bundled plugins are // shipped with the daemon image and the bundled boot walker // re-registers them on every boot. Letting an operator // 'upgrade' a bundled plugin would silently overwrite the // daemon's authoritative copy and confuse the next boot. app.post('/api/plugins/:id/upgrade', async (req, res) => { const id = req.params.id; const body = req.body && typeof req.body === 'object' ? req.body : {}; const policy = body.policy === 'pinned' ? 'pinned' : 'latest'; const plugin = getInstalledPlugin(db, id); if (!plugin) { return res.status(404).json({ error: { code: 'plugin-not-found', message: `No installed plugin with id "${id}".`, data: { id } }, }); } if (plugin.sourceKind === 'bundled') { return res.status(409).json({ error: { code: 'bundled-plugin', message: `Plugin "${id}" was shipped bundled with the daemon and upgrades only via daemon-image upgrade. The bundled boot walker re-registers bundled plugins on every boot.`, data: { id, sourceKind: plugin.sourceKind }, }, }); } let source = plugin.source; let marketplaceResolution: { marketplaceId: string; marketplaceTrust: 'official' | 'trusted' | 'restricted'; pluginName: string; pluginVersion: string; source: string; ref?: string; manifestDigest?: string; archiveIntegrity?: string; } | null = null; if (policy === 'latest' && plugin.sourceMarketplaceEntryName) { const { resolvePluginInMarketplaces } = await import('./plugins/marketplaces.js'); marketplaceResolution = resolvePluginInMarketplaces(db, plugin.sourceMarketplaceEntryName); if (marketplaceResolution) { source = marketplaceResolution.source; } } if (!source) { return res.status(409).json({ error: { code: 'missing-source', message: `Plugin "${id}" has no recorded install source — cannot upgrade. Reinstall via 'od plugin install --source <...>' to set one.`, data: { id }, }, }); } res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders?.(); const writeEvent = (event: string, data: unknown) => { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); }; writeEvent('progress', { kind: 'progress', phase: 'resolving', message: `Upgrading ${id} from ${source} (policy=${policy})` }); try { for await (const ev of installPlugin(db, { source, roots: PLUGIN_REGISTRY_ROOTS, eventKind: 'upgraded', sourceMarketplaceId: marketplaceResolution?.marketplaceId ?? plugin.sourceMarketplaceId, sourceMarketplaceEntryName: marketplaceResolution?.pluginName ?? plugin.sourceMarketplaceEntryName, sourceMarketplaceEntryVersion: marketplaceResolution?.pluginVersion ?? plugin.sourceMarketplaceEntryVersion, marketplaceTrust: marketplaceResolution?.marketplaceTrust ?? plugin.marketplaceTrust, resolvedSource: marketplaceResolution?.source ?? plugin.resolvedSource, resolvedRef: marketplaceResolution?.ref ?? plugin.resolvedRef, manifestDigest: marketplaceResolution?.manifestDigest ?? plugin.manifestDigest, archiveIntegrity: marketplaceResolution?.archiveIntegrity ?? plugin.archiveIntegrity, lockfilePath: PLUGIN_LOCKFILE_PATH, })) { writeEvent(ev.kind, ev); if (ev.kind === 'success' || ev.kind === 'error') break; } } catch (err) { writeEvent('error', { kind: 'error', message: String(err), warnings: [] }); } finally { res.end(); } }); // Plan §3.A1: shared helper used by every endpoint that has to resolve // plugin context against the live registry. Skills + design systems are // walked from disk; craft is empty in v1; atoms come from the // first-party catalog. Project-scoped overrides arrive in Phase 4. async function loadPluginRegistryView() { const [skills, designSystems] = await Promise.all([ listAllSkills(), listAllDesignSystems(), ]); // Spec §23.3.3: surface the bundled scenario plugins so apply() // can fall back to the matching scenario's pipeline when the // consumer plugin omits od.pipeline. Each scenario carries a // `taskKind` that picks the match. const scenarios = collectBundledScenarios(); return { skills: skills.map((s) => ({ id: s.id, title: s.name, description: s.description })), designSystems: designSystems.map((d) => ({ id: d.id, title: d.title })), craft: [], atoms: FIRST_PARTY_ATOMS.map((a) => ({ id: a.id, label: a.label })), scenarios, }; } // Pure read off `installed_plugins`: rows whose source_kind='bundled' // AND od.kind='scenario' AND od.pipeline is non-empty become entries // the apply path can fall back to. Scenario plugins from third-party // sources are intentionally NOT trusted as defaults — the bundled // boot walker (apps/daemon/src/plugins/bundled.ts) is the only writer // of source_kind='bundled', so this function never grants the // privilege to user-installed scenarios. // // Plan §3.O1 / §C-stage of plugin-driven-flow-plan: more than one // bundled scenario may share a `taskKind` (e.g. `od-media-generation` // also claims `new-generation` so the kind → scenario map can route // image / video / audio projects to it). The pipeline-fallback // resolver expects ONE scenario per taskKind, so this function // dedupes and prefers the canonical id `od-` as the // pipeline-fallback winner. Non-canonical scenarios still install // and run through their explicit pluginId path; they just don't get // to hijack a consumer plugin that omitted `od.pipeline`. function collectBundledScenarios() { type ScenarioEntry = { id: string; taskKind: 'new-generation' | 'figma-migration' | 'code-migration' | 'tune-collab'; pipeline: NonNullable['pipeline']>; }; const byTaskKind = new Map(); try { const all = listInstalledPlugins(db); for (const row of all) { if (row.sourceKind !== 'bundled') continue; const od = row.manifest.od; if (!od || od.kind !== 'scenario') continue; if (!od.pipeline || !Array.isArray(od.pipeline.stages) || od.pipeline.stages.length === 0) continue; const taskKind = (od.taskKind ?? 'new-generation') as ScenarioEntry['taskKind']; if (taskKind !== 'new-generation' && taskKind !== 'figma-migration' && taskKind !== 'code-migration' && taskKind !== 'tune-collab') continue; const entry: ScenarioEntry = { id: row.id, taskKind, pipeline: od.pipeline }; const existing = byTaskKind.get(taskKind); if (!existing || entry.id === `od-${taskKind}`) { byTaskKind.set(taskKind, entry); } } } catch { // On a fresh install the table may not exist yet; surface no // scenarios rather than crash the apply path. return []; } return Array.from(byTaskKind.values()); } app.post('/api/plugins/:id/apply', async (req, res) => { try { const plugin = getInstalledPlugin(db, req.params.id); if (!plugin) return res.status(404).json({ error: 'plugin not found' }); const body = req.body && typeof req.body === 'object' ? req.body : {}; const inputs = body.inputs && typeof body.inputs === 'object' ? body.inputs : {}; const grantCaps = Array.isArray(body.grantCaps) ? body.grantCaps.filter((c) => typeof c === 'string') : []; const locale = typeof body.locale === 'string' ? body.locale : undefined; const registry = await loadPluginRegistryView(); const computed = applyPlugin({ plugin, inputs, registry, locale }); // Plan §3.B2 — apply-time grants are merged into the snapshot's // capabilitiesGranted so the §9 capability gate sees them, but // they are NOT written back to installed_plugins.capabilities_granted. // The snapshot is the only place this ephemeral grant lives. if (grantCaps.length > 0) { const merged = new Set([...computed.result.capabilitiesGranted, ...grantCaps]); computed.result.capabilitiesGranted = Array.from(merged); computed.result.appliedPlugin.capabilitiesGranted = Array.from(merged); } res.json({ ok: true, ...computed.result, warnings: computed.warnings, manifestSourceDigest: computed.manifestSourceDigest }); } catch (err) { if (err instanceof MissingInputError) { return res.status(422).json({ error: 'missing_inputs', fields: err.fields }); } res.status(500).json({ error: String(err) }); } }); app.post('/api/plugins/:id/share-project', async (req, res) => { try { const sourcePlugin = getInstalledPlugin(db, req.params.id); if (!sourcePlugin) { sendApiError(res, 404, 'NOT_FOUND', 'plugin not found'); return; } if (!USER_PLUGIN_SOURCE_KINDS.has(sourcePlugin.sourceKind)) { res.status(409).json({ ok: false, code: 'plugin-not-shareable', message: 'Only user-installed plugins can start a share project.', }); return; } const body = req.body && typeof req.body === 'object' ? req.body : {}; const action = normalizePluginShareAction(body.action); if (!action) { sendApiError(res, 400, 'BAD_REQUEST', 'action must be publish-github or contribute-open-design'); return; } const actionPluginId = PLUGIN_SHARE_ACTION_PLUGIN_IDS[action]; const actionPlugin = getInstalledPlugin(db, actionPluginId); if (!actionPlugin) { res.status(409).json({ ok: false, code: 'share-action-plugin-missing', message: `The bundled action plugin "${actionPluginId}" is not installed. Restart the daemon so bundled plugins are registered.`, }); return; } const now = Date.now(); const id = randomId(); const cid = randomId(); const sourceSlug = githubRepoNameFromPluginName(sourcePlugin.id); const stagedPath = `plugin-source/${sourceSlug}`; const prompt = renderPluginSharePrompt({ action, sourcePlugin, stagedPath }); const metadata = { kind: 'prototype' }; const projectRoot = await ensureProject(PROJECTS_DIR, id, metadata); await copyPluginFolderForProjectContext( sourcePlugin.fsPath, path.join(projectRoot, 'plugin-source', sourceSlug), ); insertProject(db, { id, name: `${PLUGIN_SHARE_ACTION_LABELS[action]}: ${sourcePlugin.title || sourcePlugin.id}`, skillId: null, designSystemId: null, pendingPrompt: prompt, metadata, createdAt: now, updatedAt: now, }); insertConversation(db, { id: cid, projectId: id, title: null, createdAt: now, updatedAt: now, }); const registry = await loadPluginRegistryView(); const resolved = resolvePluginSnapshot({ db, body: { pluginId: actionPluginId, pluginInputs: { source_plugin_id: sourcePlugin.id, source_plugin_title: sourcePlugin.title || sourcePlugin.id, source_plugin_version: sourcePlugin.version, source_plugin_path: sourcePlugin.fsPath, plugin_context_path: stagedPath, }, locale: typeof body.locale === 'string' ? body.locale : undefined, }, projectId: id, conversationId: cid, registry, }); if (resolved && !resolved.ok) { res.status(resolved.status).json(resolved.body); return; } const project = getProject(db, id); if (!project) { sendApiError(res, 500, 'INTERNAL_ERROR', 'created project could not be loaded'); return; } res.json({ ok: true, project, conversationId: cid, ...(resolved?.ok ? { appliedPluginSnapshotId: resolved.snapshotId } : {}), actionPluginId, sourcePluginId: sourcePlugin.id, stagedPath, prompt, message: `Created a ${PLUGIN_SHARE_ACTION_LABELS[action]} task for ${sourcePlugin.title || sourcePlugin.id}.`, }); } catch (err) { res.status(400).json({ ok: false, message: String(err?.message || err) }); } }); app.post('/api/plugins/:id/doctor', async (req, res) => { try { const plugin = getInstalledPlugin(db, req.params.id); if (!plugin) return res.status(404).json({ error: 'plugin not found' }); const registry = await loadPluginRegistryView(); const report = doctorPlugin(plugin, registry); res.json(report); } catch (err) { res.status(500).json({ error: String(err) }); } }); // Plan §3.A2 / spec §9.1: persistent capability grant. Body is // `{ capabilities: string[], action?: 'grant' | 'revoke' }`. The daemon // validates each entry against the §5.3 vocabulary; unknown / malformed // strings come back as 400 with the offending list so the CLI can // render exit-code-2 usage advice. The mutation goes through // `grantCapabilities` / `revokeCapabilities` (the only writers of // `installed_plugins.capabilities_granted` outside of install). app.post('/api/plugins/:id/trust', async (req, res) => { try { const plugin = getInstalledPlugin(db, req.params.id); if (!plugin) return res.status(404).json({ error: 'plugin not found' }); const body = req.body && typeof req.body === 'object' ? req.body : {}; const action = body.action === 'revoke' ? 'revoke' : 'grant'; const { validateCapabilityList, grantCapabilities, revokeCapabilities } = await import('./plugins/trust.js'); const { accepted, rejected } = validateCapabilityList(body.capabilities); if (rejected.length > 0) { return res.status(400).json({ error: { code: 'invalid-capability', message: `Capability validation failed: ${rejected.map((r) => r.capability).join(', ')}`, data: { rejected }, }, }); } if (accepted.length === 0) { return res.status(400).json({ error: { code: 'no-capabilities', message: 'capabilities[] is required and must contain at least one entry', }, }); } const next = action === 'revoke' ? revokeCapabilities({ db, pluginId: req.params.id, capabilities: accepted }) : grantCapabilities({ db, pluginId: req.params.id, capabilities: accepted }); const updated = getInstalledPlugin(db, req.params.id); // Plan §3.JJ1 — emit a 'plugin.trust-changed' event so the // ops live-tail surfaces capability mutations for security // audit. Best-effort. try { const { recordPluginEvent } = await import('./plugins/events.js'); recordPluginEvent({ kind: 'plugin.trust-changed', pluginId: req.params.id, details: { action, capabilities: accepted, total: next.length }, }); } catch { // ignore — event recording never blocks the trust mutation. } res.status(action === 'grant' ? 201 : 200).json({ ok: true, id: req.params.id, action, capabilitiesGranted: next, plugin: updated, }); } catch (err) { res.status(500).json({ error: String(err) }); } }); app.get('/api/atoms', (_req, res) => { res.json({ atoms: FIRST_PARTY_ATOMS.map((a) => ({ ...a, taskKinds: a.taskKinds.slice() })) }); }); // Plan §3.AA2 — `od atoms info `. Returns the catalog row + // the bundled SKILL.md body (when one exists at // plugins/_official/atoms//SKILL.md) so the caller can render // a single page describing what the atom does + the prompt // fragment that drives it. app.get('/api/atoms/:id', async (req, res) => { const id = req.params.id; const atom = FIRST_PARTY_ATOMS.find((a) => a.id === id); if (!atom) return res.status(404).json({ error: { code: 'atom-not-found', message: `Unknown atom "${id}"` } }); const body: Record = { ...atom, taskKinds: atom.taskKinds.slice(), }; try { const { loadAtomBodies } = await import('./plugins/atom-bodies.js'); const bodies = await loadAtomBodies(db, [id]); if (bodies[0] && typeof bodies[0].body === 'string') { body.skillBody = bodies[0].body; } } catch (err) { // Best-effort; atom info still useful without the body. console.warn(`[atoms] failed to load SKILL.md body for ${id}:`, err); } res.json(body); }); // Plan §3.L3 / spec §10.3.5 / §9.2 — plugin asset endpoint. // // Serves a static file from inside an installed plugin's fsPath, // sandboxed by: // - whitelisted plugin ids (the registry row), // - normalized relpath (no '..' / absolute / leading drive), // - the §9.2 preview CSP (default-src 'none'; script-src 'self' // 'unsafe-inline'; connect-src 'none'; frame-ancestors 'self'), // - X-Content-Type-Options: nosniff so the browser respects the // declared content type even on miss. // The web GenUISurfaceRenderer's SandboxedComponentSurface points // its iframe at this URL. // Helper for the /preview + /example/:name routes below. Walks a // list of candidate relpaths inside the plugin folder, picks the // first one that exists + stays inside the fsPath, and serves it // with the §9.2 sandboxed-iframe CSP (same shape as `/asset/*`). // Pulled out so /preview and /example/:name share a single source // of truth for the security envelope. async function servePluginSandboxedHtml( req: any, res: any, pickCandidates: (plugin: any) => Promise | string[], ): Promise { try { const plugin = getInstalledPlugin(db, req.params.id); if (!plugin) { res.status(404).json({ error: 'plugin not found' }); return; } const candidates = (await pickCandidates(plugin)).filter( (p): p is string => typeof p === 'string' && p.length > 0, ); const path = await import('node:path'); const fsp = await import('node:fs/promises'); const root = path.resolve(plugin.fsPath) + path.sep; let resolved: string | null = null; let resolvedRel: string | null = null; for (const rel of candidates) { if (rel.includes('..') || rel.startsWith('/') || rel.includes('\0')) continue; const full = path.resolve(plugin.fsPath, rel); if (!(full + path.sep).startsWith(root) && full !== path.resolve(plugin.fsPath)) continue; try { const st = await fsp.stat(full); // Refuse symlinks — the install root may be writable so a // symlink leak would defeat the containment check above. const lst = await fsp.lstat(full); if (lst.isSymbolicLink()) continue; if (!st.isFile()) continue; // 5 MiB cap — preview HTML is human-authored; refuse anything // resembling a binary blob smuggled through this surface. if (st.size > 5 * 1024 * 1024) { res.status(413).json({ error: 'preview asset too large' }); return; } resolved = full; resolvedRel = rel; break; } catch { // try next candidate } } if (!resolved) { res.status(404).json({ error: 'preview not found' }); return; } let contentPath = resolved; let contentRel = resolvedRel; let buf = await fsp.readFile(resolved); if (resolvedRel && /\.html?$/i.test(resolvedRel)) { const shellTarget = iframeOnlyHtmlShellTarget(buf.toString('utf8')); if (shellTarget) { const targetFull = path.resolve(path.dirname(resolved), shellTarget); const rootDir = path.resolve(plugin.fsPath); const insideRoot = (targetFull + path.sep).startsWith(root) || targetFull === rootDir; if (insideRoot) { try { const st = await fsp.stat(targetFull); const lst = await fsp.lstat(targetFull); if (!lst.isSymbolicLink() && st.isFile() && st.size <= 5 * 1024 * 1024) { buf = await fsp.readFile(targetFull); contentPath = targetFull; contentRel = path.relative(plugin.fsPath, targetFull).split(path.sep).join('/'); } } catch { // Keep the wrapper HTML if the iframe target cannot be read. } } } } if (resolvedRel && /(^|\/)example-slides\.html$/i.test(resolvedRel)) { const templateRel = resolvedRel.replace( /(^|\/)example-slides\.html$/i, '$1template.html', ); const templateFull = path.resolve(plugin.fsPath, templateRel); const templateInside = (templateFull + path.sep).startsWith(root) || templateFull === path.resolve(plugin.fsPath); if (templateInside) { try { const st = await fsp.stat(templateFull); const lst = await fsp.lstat(templateFull); if (!lst.isSymbolicLink() && st.isFile() && st.size <= 5 * 1024 * 1024) { const title = typeof plugin.title === 'string' ? plugin.title : typeof plugin.manifest?.title === 'string' ? plugin.manifest.title : req.params.id; const tplHtml = await fsp.readFile(templateFull, 'utf8'); const slidesHtml = buf.toString('utf8'); buf = Buffer.from(assembleExample(tplHtml, slidesHtml, title), 'utf8'); contentPath = templateFull; contentRel = templateRel; } } catch { // Keep the raw fallback if the companion template is missing. } } } res.setHeader( 'Content-Security-Policy', "default-src 'none'; img-src 'self' data: blob:; media-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-ancestors 'self'", ); res.setHeader('X-Content-Type-Options', 'nosniff'); const ext = path.extname(contentPath).toLowerCase(); const ct = ext === '.html' ? 'text/html; charset=utf-8' : ext === '.js' ? 'application/javascript; charset=utf-8' : ext === '.css' ? 'text/css; charset=utf-8' : ext === '.json' ? 'application/json; charset=utf-8' : ext === '.svg' ? 'image/svg+xml' : ext === '.png' ? 'image/png' : ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'application/octet-stream'; res.setHeader('Content-Type', ct); if (ext === '.html' && typeof contentRel === 'string') { buf = Buffer.from( rewritePluginAssetUrls( buf.toString('utf8'), req.params.id, path.posix.dirname(contentRel.replace(/\\/g, '/')), ), 'utf8', ); } res.send(buf); } catch (err) { res.status(500).json({ error: String(err) }); } } function iframeOnlyHtmlShellTarget(html: string): string | null { if (typeof html !== 'string' || html.length === 0) return null; const bodyMatch = /]*>([\s\S]*?)<\/body>/i.exec(html); if (!bodyMatch) return null; const body = bodyMatch[1].replace(//g, '').trim(); const iframeMatch = /^]*\bsrc\s*=\s*(['"])([^'"]+)\1[^>]*>\s*(?:<\/iframe>)?\s*$/i.exec(body); if (!iframeMatch) return null; const src = iframeMatch[2].trim(); if ( !src || src.startsWith('/') || src.startsWith('//') || src.includes('\0') || /^[a-z][a-z0-9+.-]*:/i.test(src) ) { return null; } const pathOnly = src.split(/[?#]/)[0] ?? ''; if (!/\.html?$/i.test(pathOnly)) return null; return pathOnly; } function rewritePluginAssetUrls(html: string, pluginId: string, baseDir: string) { if (typeof html !== 'string' || html.length === 0) return html; const safeBase = baseDir === '.' ? '' : baseDir; return html.replace( /(\s(?:src|href|poster)\s*=\s*)(['"])([^'"]+)(\2)/gi, (match, attr, quote, rawValue, closeQuote) => { const value = String(rawValue).trim(); if ( !value || value.startsWith('#') || value.startsWith('/') || value.startsWith('//') || value.includes('\0') || /^[a-z][a-z0-9+.-]*:/i.test(value) ) { return match; } const splitAt = value.search(/[?#]/); const rel = splitAt === -1 ? value : value.slice(0, splitAt); const suffix = splitAt === -1 ? '' : value.slice(splitAt); const normalized = path.posix.normalize(path.posix.join(safeBase, rel)); if ( normalized === '.' || normalized === '..' || normalized.startsWith('../') || path.posix.isAbsolute(normalized) ) { return match; } const url = `/api/plugins/${encodeURIComponent(pluginId)}/asset/${normalized}${suffix}`; return `${attr}${quote}${url}${closeQuote}`; }, ); } // Plan §6 Phase 2B + spec §11.6 / §9.2 — plugin preview + examples. // // Two flavours wrap the same sandboxed-HTML envelope as `/asset/*`: // - `/preview` serves the plugin's preview entry (declared via // `od.preview.entry`, with fallbacks that walk the plugin's // own context.assets[] HTMLs, examples/*.html and assets/*.html). // - `/example/:name` serves an entry from `od.useCase.exampleOutputs[]`, // matched by basename or by index. Both reuse the same // traversal / containment guards as the asset route. // // The marketplace detail page (PluginDetailView) embeds /preview // inside an `