diff --git a/package-lock.json b/package-lock.json index 2a053617..7ae38279 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2411,9 +2411,9 @@ } }, "node_modules/devalue": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", - "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index f1987064..396d39eb 100644 --- a/package.json +++ b/package.json @@ -61,5 +61,8 @@ "dompurify": "^3.3.1", "three": "^0.182.0", "zod": "^3.25.76" + }, + "overrides": { + "devalue": ">=5.8.1" } } diff --git a/src/lib/components/MissionBoard.svelte b/src/lib/components/MissionBoard.svelte index 6f16045a..cb968efa 100644 --- a/src/lib/components/MissionBoard.svelte +++ b/src/lib/components/MissionBoard.svelte @@ -195,6 +195,7 @@ if (!iso) return '-'; try { const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; const ms = d.getTime() - Date.now(); const localTime = d.toLocaleString(undefined, { weekday: 'short', @@ -285,7 +286,9 @@ function scheduleCell(rec: ScheduleRecord): string { const pattern = humanizeCron(rec.cron); if (!rec.nextFireAt) return pattern; - const ms = new Date(rec.nextFireAt).getTime() - Date.now(); + const nextMs = new Date(rec.nextFireAt).getTime(); + if (Number.isNaN(nextMs)) return pattern; + const ms = nextMs - Date.now(); if (ms <= 0) return `${pattern} · due now`; const s = Math.floor(ms / 1000); let rel: string; diff --git a/src/lib/server/access-execution-actions.ts b/src/lib/server/access-execution-actions.ts index 6dfe9a23..d36f8522 100644 --- a/src/lib/server/access-execution-actions.ts +++ b/src/lib/server/access-execution-actions.ts @@ -152,7 +152,8 @@ export function resolveSparkCliBinary(): string { return matches.find((line) => line.toLowerCase().endsWith('.cmd')) || matches[0] || 'spark'; } return matches[0] || 'spark'; - } catch { + } catch (err) { + console.warn('[access-execution-actions] spark binary lookup failed:', err); return 'spark'; } } diff --git a/src/lib/server/brief-enricher.ts b/src/lib/server/brief-enricher.ts index 1a6cc14e..ee9d5118 100644 --- a/src/lib/server/brief-enricher.ts +++ b/src/lib/server/brief-enricher.ts @@ -31,9 +31,15 @@ import { resolveCliBinary } from './cli-resolver'; // Aggressive timeout: enrichment is a nice-to-have. If claude can't // respond fast, use deterministic assumptions/questions so the user's // request doesn't stall the bot. Override via BRIEF_ENRICH_TIMEOUT_MS. -const ENRICH_TIMEOUT_MS = Number(process.env.BRIEF_ENRICH_TIMEOUT_MS || 12_000); -const ENRICH_MIN_LENGTH = Number(process.env.BRIEF_ENRICH_MIN_LENGTH || 600); -const ENRICH_MIN_KEYWORDS = Number(process.env.BRIEF_ENRICH_MIN_KEYWORDS || 8); +function positiveNumericEnv(raw: string | undefined, fallback: number): number { + const trimmed = (raw || '').trim(); + if (!/^\d+(?:\.\d+)?$/.test(trimmed)) return fallback; + const parsed = Number(trimmed); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} +const ENRICH_TIMEOUT_MS = positiveNumericEnv(process.env.BRIEF_ENRICH_TIMEOUT_MS, 12_000); +const ENRICH_MIN_LENGTH = positiveNumericEnv(process.env.BRIEF_ENRICH_MIN_LENGTH, 600); +const ENRICH_MIN_KEYWORDS = positiveNumericEnv(process.env.BRIEF_ENRICH_MIN_KEYWORDS, 8); const ENRICH_PROVIDER = (process.env.SPAWNER_BRIEF_ENRICH_PROVIDER || 'deterministic').trim().toLowerCase(); export interface EnrichmentResult { diff --git a/src/lib/server/command-runner.ts b/src/lib/server/command-runner.ts index 872057bf..34f7eb41 100644 --- a/src/lib/server/command-runner.ts +++ b/src/lib/server/command-runner.ts @@ -14,6 +14,7 @@ import { externalProjectPathsAllowed, resolveContainedPath, sparkWorkspaceRoot } export const MAX_OUTPUT_LENGTH = 5000; export const COMMAND_TIMEOUT_MS = commandTimeoutMs(); +const SIGTERM_GRACE_MS = 5000; export interface CommandResult { exitCode: number; @@ -159,6 +160,19 @@ export function runCommand( windowsHide: true }); + // Node sends SIGTERM when the spawn `timeout` fires. If the child ignores + // SIGTERM (npm/test shells with their own signal handlers, hung worker + // pools), `close` never fires and the API caller waits indefinitely. + // Schedule a SIGKILL escalation that fires only if the child is still + // running after the SIGTERM grace window. + const sigkillTimer = setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + // Child may already have exited after SIGTERM. + } + }, timeoutMs + SIGTERM_GRACE_MS); + child.stdout?.on('data', (data: Buffer) => { stdout += data.toString(); }); @@ -168,6 +182,7 @@ export function runCommand( }); child.on('close', (code) => { + clearTimeout(sigkillTimer); if (!resolved) { resolved = true; res({ @@ -180,6 +195,7 @@ export function runCommand( }); child.on('error', (err) => { + clearTimeout(sigkillTimer); if (!resolved) { resolved = true; res({ @@ -222,7 +238,8 @@ export async function detectTypecheckCommand(projectPath: string): Promise<{ com if (deps['typescript']) { return { command: 'npx', args: ['tsc', '--noEmit'] }; } - } catch { + } catch (err) { + console.warn('[command-runner] dependency check failed:', err); // fall through } } @@ -238,7 +255,8 @@ export async function hasTestScript(projectPath: string): Promise { try { const pkg = JSON.parse(await readFile(pkgPath, 'utf-8')); return !!(pkg.scripts?.test && pkg.scripts.test !== 'echo "Error: no test specified" && exit 1'); - } catch { + } catch (err) { + console.warn('[command-runner] package.json parse failed:', err); return false; } } diff --git a/src/lib/server/creator-mission.ts b/src/lib/server/creator-mission.ts index add3b1ae..dc8ec5ea 100644 --- a/src/lib/server/creator-mission.ts +++ b/src/lib/server/creator-mission.ts @@ -1,5 +1,6 @@ import { env } from '$env/dynamic/private'; import { execFile } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'; import path from 'node:path'; @@ -368,6 +369,14 @@ function creatorMissionDir(stateDir = spawnerStateDir()): string { return path.join(stateDir, 'creator-missions'); } +/** + * Validates that a missionId contains only safe characters and cannot be used for path traversal. + * Mission IDs should be alphanumeric with hyphens/underscores only. + */ +function validateMissionId(missionId: string): boolean { + return /^[a-zA-Z0-9_-]+$/.test(missionId); +} + function pendingLoadPath(stateDir = spawnerStateDir()): string { return path.join(stateDir, 'pending-load.json'); } @@ -1632,7 +1641,7 @@ export async function saveCreatorMissionTrace(trace: CreatorMissionTrace, stateD const dir = creatorMissionDir(stateDir); await mkdir(dir, { recursive: true }); const filePath = creatorMissionPath(trace.mission_id, stateDir); - const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + const tempPath = `${filePath}.${randomUUID()}.tmp`; await writeFile(tempPath, JSON.stringify(trace, null, 2), 'utf-8'); await rename(tempPath, filePath); } @@ -1651,6 +1660,10 @@ export async function readCreatorMissionTrace( ): Promise { const missionId = input.missionId?.trim(); if (missionId) { + if (!validateMissionId(missionId)) { + console.error(`Invalid missionId format: ${missionId}`); + return null; + } const filePath = creatorMissionPath(missionId, stateDir); if (!existsSync(filePath)) return null; return parseCreatorMissionTraceFile(await readFile(filePath, 'utf-8')); @@ -1727,6 +1740,32 @@ export async function executeCreatorMission( const VALIDATION_EXECUTABLE_ALLOWLIST = new Set(['node', 'python', 'python3', 'py', 'npm', 'npx', 'pnpm', 'spark-intelligence']); +// Interpreters that accept flags to execute arbitrary code snippets. +const INTERPRETER_EXECUTABLES = new Set(['node', 'python', 'python3', 'py']); +// Flags that cause interpreters to evaluate an arbitrary code string from the argument +// line (or read a program from stdin). +// A command like `python -c "os.system('rm -rf /')"` would pass the allowlist +// because only the executable name is checked. These inline-eval flags must be rejected. +// Covers node (-e/--eval, -p/--print), python (-c/--command) and the bare `-` +// stdin-program form. This list must include every interpreter flag that evaluates an +// argument string or stdin program rather than naming a script/module on disk. +// +// NOTE: `-m`/`--module` is intentionally NOT listed. `python -m ` runs a named, +// importable module (e.g. `python -m pytest tests`) — it executes installed code on disk, +// exactly like running a script file, and never evaluates an arbitrary inline string. It +// is a first-class validation pattern here (the default manifest validation command is +// `python -m pytest tests`, and the planner shells out `python -m spark_intelligence.cli`), +// so blocking it would break legitimate runs. +const DANGEROUS_INTERPRETER_FLAGS = new Set([ + '-c', + '--command', + '-e', + '--eval', + '-p', + '--print', + '-' +]); + function splitCommandLine(command: string): string[] { const parts: string[] = []; let current = ''; @@ -1966,6 +2005,24 @@ async function runCreatorValidationCommand( error: `Validation executable is not allowlisted: ${executable}` }; } + // Reject interpreters invoked with flags that execute arbitrary code snippets. + if (INTERPRETER_EXECUTABLES.has(executable)) { + const hasDangerousFlag = args.some((arg) => DANGEROUS_INTERPRETER_FLAGS.has(arg)); + if (hasDangerousFlag) { + return { + artifact_id: manifest.artifact_id, + artifact_type: manifest.artifact_type, + repo: manifest.repo, + command, + cwd, + status: 'skipped', + exit_code: null, + stdout_tail: '', + stderr_tail: '', + error: `Interpreter executable '${executable}' is not permitted with code-execution flags. Use a script file instead.` + }; + } + } const runner = options.commandRunner || activeCreatorValidationCommandRunner || defaultCreatorValidationCommandRunner; const resolved = options.commandRunner ? { executable, args } : resolveValidationCommand(executable, args); const result = await runner(resolved.executable, resolved.args, { cwd, timeoutMs: options.timeoutMs }); diff --git a/src/lib/server/hosted-ui-auth.test.ts b/src/lib/server/hosted-ui-auth.test.ts index 79677e96..326a3c98 100644 --- a/src/lib/server/hosted-ui-auth.test.ts +++ b/src/lib/server/hosted-ui-auth.test.ts @@ -355,9 +355,12 @@ describe('hosted UI auth', () => { expect(hostedUiCredentialsAreValid('my-private-spawner', 'wrong', env)).toBe(false); }); - it('uses forwarded IP as the hosted auth rate-limit key', () => { + it('uses the trusted-proxy (last) forwarded-for hop as the hosted auth rate-limit key', () => { + // The leftmost x-forwarded-for entry is client-controllable and can be spoofed to + // dodge the rate limiter. The hardened key derivation trusts only the last hop, + // which is appended by our reverse proxy. const request = new Request('https://x.test/', { headers: { 'x-forwarded-for': '203.0.113.10, 10.0.0.1' } }); - expect(hostedUiAuthClientKey(request)).toBe('203.0.113.10'); + expect(hostedUiAuthClientKey(request)).toBe('10.0.0.1'); }); it('rate-limits repeated hosted auth failures within the window', () => { diff --git a/src/lib/server/hosted-ui-auth.ts b/src/lib/server/hosted-ui-auth.ts index 6a6c40a7..a5029e76 100644 --- a/src/lib/server/hosted-ui-auth.ts +++ b/src/lib/server/hosted-ui-auth.ts @@ -59,7 +59,13 @@ const hostedUiSessions = new Map< function constantTimeEquals(left: string, right: string): boolean { const leftBuffer = Buffer.from(left); const rightBuffer = Buffer.from(right); - return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); + // Use the longer length to prevent timing leak on length comparison + const maxLen = Math.max(leftBuffer.length, rightBuffer.length); + const leftPadded = Buffer.alloc(maxLen, 0); + const rightPadded = Buffer.alloc(maxLen, 0); + leftBuffer.copy(leftPadded); + rightBuffer.copy(rightPadded); + return timingSafeEqual(leftPadded, rightPadded) && leftBuffer.length === rightBuffer.length; } function hashSessionId(sessionId: string): string { @@ -217,8 +223,16 @@ export function hostedUiShouldBypassLocalOperatorAuth( } export function hostedUiAuthClientKey(request: Request): string { - const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); - return forwardedFor || request.headers.get('x-real-ip')?.trim() || 'unknown'; + // Prefer x-real-ip (set by trusted reverse proxy) over x-forwarded-for. + // x-forwarded-for first entry is client-controllable; use last entry (set by trusted proxy). + const realIp = request.headers.get('x-real-ip')?.trim(); + if (realIp) return realIp; + const forwardedFor = request.headers.get('x-forwarded-for'); + if (forwardedFor) { + const parts = forwardedFor.split(',').map(p => p.trim()).filter(Boolean); + if (parts.length > 0) return parts[parts.length - 1]; + } + return 'unknown'; } export function hostedUiAuthRateLimitStatus(clientKey: string, now = Date.now()): { diff --git a/src/lib/server/mcp-auth.ts b/src/lib/server/mcp-auth.ts index 5a399acd..84333df4 100644 --- a/src/lib/server/mcp-auth.ts +++ b/src/lib/server/mcp-auth.ts @@ -13,12 +13,13 @@ const rateLimitBuckets = new Map(); function constantTimeEquals(left: string, right: string): boolean { const leftBuffer = Buffer.from(left); const rightBuffer = Buffer.from(right); - - if (leftBuffer.length !== rightBuffer.length) { - return false; - } - - return timingSafeEqual(leftBuffer, rightBuffer); + // Use the longer length to prevent timing leak on length comparison + const maxLen = Math.max(leftBuffer.length, rightBuffer.length); + const leftPadded = Buffer.alloc(maxLen, 0); + const rightPadded = Buffer.alloc(maxLen, 0); + leftBuffer.copy(leftPadded); + rightBuffer.copy(rightPadded); + return timingSafeEqual(leftPadded, rightPadded) && leftBuffer.length === rightBuffer.length; } interface ApiKeyExtractionOptions { diff --git a/src/lib/server/mission-control-relay.ts b/src/lib/server/mission-control-relay.ts index bad9a5c1..65aab0ca 100644 --- a/src/lib/server/mission-control-relay.ts +++ b/src/lib/server/mission-control-relay.ts @@ -174,7 +174,7 @@ function getMissionControlPersistenceInfo(): MissionControlRelaySnapshot['persis } } -function isMissionControlMissionId(value: unknown): value is string { +export function isMissionControlMissionId(value: unknown): value is string { return typeof value === 'string' && /^(spark|mission)-[A-Za-z0-9_-]+$/.test(value.trim()); } @@ -234,9 +234,49 @@ const relayState: { recent: [] }; +// Lifecycle-state maps are server-process-lifetime; without a cap, every +// mission/task ID processed since the last restart accumulates an entry +// and the maps grow unbounded. Cap each map and evict the oldest +// insertion when the cap is reached. Map iteration order preserves +// insertion order, so the first key reached is the oldest. +const MAX_LIFECYCLE_ENTRIES = 5000; const missionLifecycleStates = new Map(); const taskLifecycleStates = new Map(); +function setBoundedLifecycle(map: Map, key: string, value: string): void { + if (!map.has(key) && map.size >= MAX_LIFECYCLE_ENTRIES) { + const oldest = map.keys().next().value; + if (oldest !== undefined) map.delete(oldest); + } + map.set(key, value); +} + +// Seed lifecycle dedup maps from the persisted recent entries so that, after a +// server restart, replays of an already-recorded terminal lifecycle event are +// skipped instead of re-broadcast to Spark ingest and webhooks. +function seedLifecycleStatesFromRecent(): void { + // recent is newest-first (unshift); walk oldest-first so the latest status wins. + // recent is capped (MAX_RECENT_EVENTS), so this seeds far fewer than + // MAX_LIFECYCLE_ENTRIES entries; route through setBoundedLifecycle to keep a + // single insertion path and preserve the cap invariant. + for (let i = relayState.recent.length - 1; i >= 0; i -= 1) { + const entry = relayState.recent[i]; + const taskStatus = lifecycleTaskStatusForEvent(entry.eventType); + if (taskStatus) { + const identity = taskIdentityForStatusEntry(entry); + if (identity) { + setBoundedLifecycle(taskLifecycleStates, `${entry.missionId}:${identity}`, taskStatus); + } + continue; + } + const missionStatus = lifecycleMissionStatusForEvent(entry.eventType); + if (missionStatus) { + setBoundedLifecycle(missionLifecycleStates, entry.missionId, missionStatus); + } + } +} +seedLifecycleStatesFromRecent(); + function normalizeMissionId(event: MissionControlBridgeEvent): string { return typeof event.missionId === 'string' && event.missionId.trim().length > 0 ? event.missionId : 'unknown-mission'; } @@ -433,6 +473,18 @@ function shouldRecordMissionControlEvent(event: MissionControlBridgeEvent): bool function recordRelayEvent(event: MissionControlBridgeEvent): void { const entry = toStatusEntry(event); if (!isMissionControlMissionId(entry.missionId)) { + // Surface the drop so non-conformant emitters notice their events never reach + // /kanban, /trace, or any board view. The event was accepted by /api/events + // (and is still broadcast via SSE) — only the board persistence is filtered. + // Suppress the warning for events with no missionId at all (those are never + // intended to be mission-scoped). + if (entry.missionId) { + console.warn( + `[mission-control-relay] mission event "${entry.eventType}" for missionId ` + + `"${entry.missionId}" was accepted but will not appear on the board: ` + + `missionId must match /^(spark|mission)-[A-Za-z0-9_-]+$/.` + ); + } return; } relayState.totalRelayed += 1; @@ -722,7 +774,7 @@ function shouldRecordLifecycleTransition(event: MissionControlBridgeEvent): bool if (taskLifecycleStates.get(key) === taskStatus) { return false; } - taskLifecycleStates.set(key, taskStatus); + setBoundedLifecycle(taskLifecycleStates, key, taskStatus); return true; } @@ -731,7 +783,7 @@ function shouldRecordLifecycleTransition(event: MissionControlBridgeEvent): bool if (missionLifecycleStates.get(missionId) === missionStatus) { return false; } - missionLifecycleStates.set(missionId, missionStatus); + setBoundedLifecycle(missionLifecycleStates, missionId, missionStatus); return true; } diff --git a/src/lib/server/provider-clients/retry-after.ts b/src/lib/server/provider-clients/retry-after.ts index fd8249bf..db977a01 100644 --- a/src/lib/server/provider-clients/retry-after.ts +++ b/src/lib/server/provider-clients/retry-after.ts @@ -1,3 +1,12 @@ +// Provider clients call sleep() with the value returned here before the +// next retry attempt. Without an upper bound a single 429/5xx response +// that carries `Retry-After: 86400` (one day -- some quota tiers emit +// this on hard daily caps) freezes the mission executor for 24h per +// attempt, blocking the Spawner mission queue and producing no visible +// progress to the operator. Cap the honoured delay so a misbehaving or +// quota-exhausted upstream cannot stall a mission for hours. +const MAX_RETRY_AFTER_MS = 60_000; + export function parseRetryAfterMs(headerValue: string | null, fallbackMs: number, nowMs = Date.now()): number { if (!headerValue) return fallbackMs; const trimmed = headerValue.trim(); @@ -5,13 +14,15 @@ export function parseRetryAfterMs(headerValue: string | null, fallbackMs: number if (/^\d+$/.test(trimmed)) { const seconds = Number.parseInt(trimmed, 10); - return Number.isFinite(seconds) ? seconds * 1000 : fallbackMs; + if (!Number.isFinite(seconds)) return fallbackMs; + return Math.min(seconds * 1000, MAX_RETRY_AFTER_MS); } const looksLikeHttpDate = /,/.test(trimmed) && /\bGMT\b/i.test(trimmed); const retryAtMs = looksLikeHttpDate ? Date.parse(trimmed) : Number.NaN; if (Number.isFinite(retryAtMs)) { - return Math.max(0, retryAtMs - nowMs); + const delta = Math.max(0, retryAtMs - nowMs); + return Math.min(delta, MAX_RETRY_AFTER_MS); } return fallbackMs; diff --git a/src/lib/server/provider-clients/spark-harness-client.ts b/src/lib/server/provider-clients/spark-harness-client.ts index 90beceff..065639ec 100644 --- a/src/lib/server/provider-clients/spark-harness-client.ts +++ b/src/lib/server/provider-clients/spark-harness-client.ts @@ -33,6 +33,7 @@ interface AssignedCanvasTask { const DEFAULT_SPARK_HARNESS_URL = 'http://127.0.0.1:8011'; const POLL_INTERVAL_MS = 1500; +const STATUS_POLL_MAX_TRANSIENT_FAILURES = 3; const DEFAULT_TIMEOUT_MS = sparkHarnessTimeoutMs(); export async function executeSparkHarnessRequest(options: SparkHarnessOptions): Promise { @@ -425,12 +426,30 @@ async function waitForSparkTask(input: { } }; + let consecutiveStatusFailures = 0; + let lastStatusError: Error | null = null; while (Date.now() - startedAt < DEFAULT_TIMEOUT_MS) { if (signal?.aborted) { return { success: false, error: 'Cancelled', durationMs: Date.now() - startedAt }; } - const status = await getSparkTaskStatus(baseUrl, taskId, signal); + let status: SparkTaskStatus; + try { + status = await getSparkTaskStatus(baseUrl, taskId, signal); + consecutiveStatusFailures = 0; + lastStatusError = null; + } catch (err) { + if (signal?.aborted) { + return { success: false, error: 'Cancelled', durationMs: Date.now() - startedAt }; + } + consecutiveStatusFailures += 1; + lastStatusError = err instanceof Error ? err : new Error(String(err)); + if (consecutiveStatusFailures >= STATUS_POLL_MAX_TRANSIENT_FAILURES) { + throw lastStatusError; + } + await sleep(POLL_INTERVAL_MS, signal); + continue; + } const state = (status.status || 'unknown').toLowerCase(); if (state !== 'completed') { syncVisualTaskProgress(progress, state); diff --git a/src/lib/server/provider-runtime.ts b/src/lib/server/provider-runtime.ts index 67f56ba4..b73f6a3b 100644 --- a/src/lib/server/provider-runtime.ts +++ b/src/lib/server/provider-runtime.ts @@ -39,6 +39,7 @@ import { import { parseJsonOrFallback } from '$lib/utils/safe-json'; import { readFile } from 'node:fs/promises'; import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; import path from 'node:path'; import { spawnerStateDir } from './spawner-state'; @@ -311,7 +312,7 @@ class ProviderRuntimeManager { try { const persistPath = getProviderResultsPath(); mkdirSync(path.dirname(persistPath), { recursive: true }); - const tempPath = `${persistPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; + const tempPath = `${persistPath}.${randomUUID()}.tmp`; writeFileSync( tempPath, JSON.stringify({ missions: Object.fromEntries(this.persistedResults) }, null, 2), diff --git a/src/lib/server/scheduler.ts b/src/lib/server/scheduler.ts index d97a2d32..6844b102 100644 --- a/src/lib/server/scheduler.ts +++ b/src/lib/server/scheduler.ts @@ -67,6 +67,7 @@ interface StoreShape { let _store: StoreShape | null = null; let _tickTimer: NodeJS.Timeout | null = null; let _starting = false; +const _firingIds = new Set(); function _id(): string { return 'sched-' + randomBytes(4).toString('hex'); @@ -282,7 +283,13 @@ async function _tick(): Promise { continue; } if (new Date(rec.nextFireAt) > now) continue; + if (_firingIds.has(rec.id)) { + // Previous fire for this schedule is still in flight (e.g. long subprocess). + // Skip so we do not relaunch the mission or emit a duplicate relay message. + continue; + } const nextFireAt = _computeNext(rec.cron, rec.timezone); + _firingIds.add(rec.id); try { const result = await _fire(rec); rec.lastFiredAt = new Date().toISOString(); @@ -293,6 +300,8 @@ async function _tick(): Promise { rec.lastFiredAt = new Date().toISOString(); rec.fireCount += 1; rec.lastStatus = 'crash: ' + errorMessage(err); + } finally { + _firingIds.delete(rec.id); } rec.nextFireAt = nextFireAt; dirty = true; diff --git a/src/lib/server/spawner-state.ts b/src/lib/server/spawner-state.ts index efcb1bc1..22b574af 100644 --- a/src/lib/server/spawner-state.ts +++ b/src/lib/server/spawner-state.ts @@ -134,7 +134,8 @@ export function spawnerStateSourceReferenceAudit(sourceRoot = process.cwd()): Sp if (owner === 'runtime') runtimeReferenceFileCount += 1; else nonRuntimeReferenceFileCount += 1; } - } catch { + } catch (err) { + console.warn('[spawner-state] file scan entry failed:', err); continue; } } @@ -147,7 +148,8 @@ export function spawnerStateSourceReferenceAudit(sourceRoot = process.cwd()): Sp let helperText = ''; try { helperText = readFileSync(path.join(sourceRoot, 'src', 'lib', 'server', 'spawner-state.ts'), 'utf-8'); - } catch { + } catch (err) { + console.warn('[spawner-state] helper text read failed:', err); helperText = ''; } diff --git a/src/lib/services/h70-skill-matcher.ts b/src/lib/services/h70-skill-matcher.ts index b58ee856..3c4a3a2a 100644 --- a/src/lib/services/h70-skill-matcher.ts +++ b/src/lib/services/h70-skill-matcher.ts @@ -556,6 +556,23 @@ const TASK_TYPE_TO_SKILLS: Record = { 'AI Integration': ['llm-architect', 'ai-agents-architect', 'prompt-engineer'], }; +/** + * Multi-word phrase keys precomputed at module load. + * + * `KEYWORD_TO_SKILLS` is a module-level constant whose key set never changes + * at runtime, so the phrase subset is invariant. Recomputing + * `Object.keys(KEYWORD_TO_SKILLS).filter(k => k.includes(' '))` inside + * `extractKeywords` walked all ~180 keys plus an extra phrase-filter pass + * on every call. `matchTaskToSkills` (and therefore `extractKeywords`) is + * invoked once per task by `mission-builder.ts`, `prd-auto-dispatch.ts`, and + * the batch helpers below — a 30-50 task mission paid that scan 30-50 times + * for the exact same result. Caching it here keeps the cost on the module's + * load tick and preserves a stable hot-path cost per task. + */ +const KEYWORD_PHRASES: readonly string[] = Object.freeze( + Object.keys(KEYWORD_TO_SKILLS).filter((k) => k.includes(' ')) +); + /** * Extract keywords from task name/description */ @@ -563,9 +580,8 @@ function extractKeywords(text: string): string[] { const normalized = text.toLowerCase(); const keywords: string[] = []; - // Check multi-word phrases first - const phrases = Object.keys(KEYWORD_TO_SKILLS).filter(k => k.includes(' ')); - for (const phrase of phrases) { + // Check multi-word phrases first (phrase list precomputed once at module load) + for (const phrase of KEYWORD_PHRASES) { if (normalized.includes(phrase)) { keywords.push(phrase); } diff --git a/src/lib/services/mission-executor.ts b/src/lib/services/mission-executor.ts index 6eb5ad7a..7e87da5c 100644 --- a/src/lib/services/mission-executor.ts +++ b/src/lib/services/mission-executor.ts @@ -396,6 +396,19 @@ class MissionExecutor { } else { log.debug('State synced to file for Claude Code resume'); } + + // Cancel propagation guard: while the POST was in-flight the operator + // may have clicked Cancel (or the mission failed/completed). The + // debounced status check at the top of this callback ran before the + // network round-trip, so a now-terminal mission would have just been + // re-persisted as "running" — Claude Code would pick that up and + // resume work the operator explicitly stopped. Re-check the live + // status and clear the stale active-mission record if so. + if (response.ok && TERMINAL_EXECUTION_STATUSES.has(this.progress.status)) { + await fetch('/api/mission/active', { method: 'DELETE', headers: getEventsAuthHeaders() }).catch((err) => { + log.warn('Failed to clear stale active-mission after terminal status:', err); + }); + } } catch (error) { log.warn('Failed to sync state to file:', error); } diff --git a/src/lib/services/sync-client.ts b/src/lib/services/sync-client.ts index aef8312a..c857c3ef 100644 --- a/src/lib/services/sync-client.ts +++ b/src/lib/services/sync-client.ts @@ -436,7 +436,15 @@ class SyncClient { syncStatus.set('reconnecting'); this.reconnectAttempts++; - const delay = this.config.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1); + // Cap base delay so attempt 10 doesn't sit idle for ~2 minutes, then + // apply +/-25% jitter so a fleet of browser tabs reconnecting after a + // server restart doesn't all hit the same offset at once. + const baseDelay = Math.min( + this.config.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1), + 30_000 + ); + const jitter = baseDelay * 0.25 * (Math.random() * 2 - 1); + const delay = Math.max(0, Math.round(baseDelay + jitter)); logger.info(`[SyncClient] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); this.reconnectTimer = setTimeout(() => { diff --git a/src/lib/stores/canvas.svelte.ts b/src/lib/stores/canvas.svelte.ts index 9d0a4e2f..503a3400 100644 --- a/src/lib/stores/canvas.svelte.ts +++ b/src/lib/stores/canvas.svelte.ts @@ -270,7 +270,7 @@ export function addNodesWithConnections( const sourceId = nodeIds[connDef.sourceIndex]; const targetId = nodeIds[connDef.targetIndex]; if (sourceId && targetId) { - const id = 'conn-' + Math.random().toString(36).slice(2, 10); + const id = 'conn-' + crypto.randomUUID(); const connection: Connection = { id, sourceNodeId: sourceId, @@ -437,7 +437,7 @@ export function duplicateSelected(): string[] { ); const newConnections: Connection[] = connectionsToDuplicate.map((conn) => ({ ...conn, - id: 'conn-' + Math.random().toString(36).slice(2, 10), + id: 'conn-' + crypto.randomUUID(), sourceNodeId: idMap[conn.sourceNodeId], targetNodeId: idMap[conn.targetNodeId] })); @@ -493,7 +493,7 @@ export function pasteFromClipboard(): string[] { // Recreate connections const newConnections: Connection[] = clipboard.connections.map((conn) => ({ ...conn, - id: 'conn-' + Math.random().toString(36).slice(2, 10), + id: 'conn-' + crypto.randomUUID(), sourceNodeId: idMap[conn.sourceNodeId], targetNodeId: idMap[conn.targetNodeId] })); @@ -523,7 +523,7 @@ export function addConnection( ): string { pushHistory(); - const id = 'conn-' + Math.random().toString(36).slice(2, 10); + const id = 'conn-' + crypto.randomUUID(); const connection: Connection = { id, sourceNodeId, @@ -1287,8 +1287,23 @@ export function enableAutoSave(debounceMs = 1000): () => void { }, debounceMs); }); + // Mirror canvas writes from sibling tabs so a second tab's view stays + // current with the first. Skip while local edits are still pending so + // we never reach into an in-progress drag/typing tab. Refresh otherwise. + const onStorage = (event: StorageEvent) => { + if (event.key !== STORAGE_KEY || event.newValue === null) return; + if (autoSaveTimeout) return; + loadCanvas(); + }; + if (browser) { + window.addEventListener('storage', onStorage); + } + return () => { unsubscribe(); + if (browser) { + window.removeEventListener('storage', onStorage); + } if (autoSaveTimeout) { clearTimeout(autoSaveTimeout); } diff --git a/src/routes/api/analyze/+server.ts b/src/routes/api/analyze/+server.ts index 8244e6b5..f1709cf2 100644 --- a/src/routes/api/analyze/+server.ts +++ b/src/routes/api/analyze/+server.ts @@ -277,8 +277,7 @@ export const POST: RequestHandler = async (event) => { log.error('Failed to parse Claude response:', textContent.text); return json({ error: 'Failed to parse Claude response', - fallback: true, - rawResponse: textContent.text.slice(0, 500) // For debugging + fallback: true }, { status: 502 }); } diff --git a/src/routes/api/creator/mission/+server.ts b/src/routes/api/creator/mission/+server.ts index cf3970c7..3605f9ae 100644 --- a/src/routes/api/creator/mission/+server.ts +++ b/src/routes/api/creator/mission/+server.ts @@ -170,5 +170,5 @@ export const GET: RequestHandler = async (event) => { if (!trace) { return json({ ok: false, error: 'creator mission trace not found' }, { status: 404 }); } - return json({ ok: true, tracePath: creatorMissionPath(trace.mission_id), trace }); + return json({ ok: true, trace }); }; diff --git a/src/routes/api/creator/mission/creator-mission.integration.test.ts b/src/routes/api/creator/mission/creator-mission.integration.test.ts index 136d93d9..4823e4e9 100644 --- a/src/routes/api/creator/mission/creator-mission.integration.test.ts +++ b/src/routes/api/creator/mission/creator-mission.integration.test.ts @@ -202,7 +202,9 @@ describe('/api/creator/mission', () => { expect(getResponse.status).toBe(200); const getBody = await getResponse.json(); expect(getBody.trace.mission_id).toBe('mission-creator-api'); - expect(getBody.tracePath).toContain('mission-creator-api.json'); + // The GET response intentionally no longer leaks the absolute server tracePath (#877 + // path-redaction series); the trace is returned without exposing the filesystem path. + expect(getBody.tracePath).toBeUndefined(); }); it('marks explicitly read-only creator mission requests as read-only', async () => { diff --git a/src/routes/api/dispatch/+server.ts b/src/routes/api/dispatch/+server.ts index 032db2fd..78407277 100644 --- a/src/routes/api/dispatch/+server.ts +++ b/src/routes/api/dispatch/+server.ts @@ -10,6 +10,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { env } from '$env/dynamic/private'; import { requireControlAuth, enforceRateLimit } from '$lib/server/mcp-auth'; +import { sparkWorkspaceRoot, isWithinDirectory, externalProjectPathsAllowed } from '$lib/server/spark-run-workspace'; import { eventBridge } from '$lib/services/event-bridge'; import { providerRuntime } from '$lib/server/provider-runtime'; import { DEFAULT_MULTI_LLM_PROVIDERS } from '$lib/services/multi-llm-orchestrator'; @@ -96,6 +97,18 @@ export const POST: RequestHandler = async (event) => { const body = await event.request.json(); const { executionPack, apiKeys, workingDirectory, relay } = body; + // Validate workingDirectory stays within allowed workspace root + if (typeof workingDirectory === 'string' && workingDirectory.trim()) { + const trimmedWorkingDir = workingDirectory.trim(); + const workspaceRoot = sparkWorkspaceRoot(); + if (!externalProjectPathsAllowed() && !isWithinDirectory(workspaceRoot, trimmedWorkingDir)) { + return json( + { success: false, error: `workingDirectory must be within workspace root (${workspaceRoot}). Received: ${trimmedWorkingDir}` }, + { status: 400 } + ); + } + } + if (!executionPack || !executionPack.providers || !Array.isArray(executionPack.providers)) { return json({ success: false, error: 'Invalid execution pack' }, { status: 400 }); } diff --git a/src/routes/api/events/+server.ts b/src/routes/api/events/+server.ts index 6eb968aa..63c2d1dc 100644 --- a/src/routes/api/events/+server.ts +++ b/src/routes/api/events/+server.ts @@ -7,10 +7,12 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; import { eventBridge } from '$lib/services/event-bridge'; import { assertSafeId, PathSafetyError, resolveWithinBaseDir } from '$lib/server/path-safety'; import { controlQueryApiKeysAllowed, enforceRateLimit, requireControlAuth } from '$lib/server/mcp-auth'; -import { relayMissionControlEvent } from '$lib/server/mission-control-relay'; +import { hostedUiHostIsLoopback } from '$lib/server/hosted-ui-auth'; +import { relayMissionControlEvent, isMissionControlMissionId } from '$lib/server/mission-control-relay'; import { providerRuntime } from '$lib/server/provider-runtime'; import { projectStoredPrdAnalysisResultForTier } from '$lib/server/prd-analysis-result-schema'; import { pendingRequestFileForRequest, readPendingRequestRecord } from '$lib/server/prd-pending-requests'; @@ -29,9 +31,52 @@ const log = logger.scope('EventBridge'); const TERMINAL_LIFECYCLE_EVENTS = new Set(['mission_completed', 'mission_failed', 'mission_cancelled']); const TERMINAL_PROVIDER_STATUSES = new Set(['completed', 'failed', 'cancelled']); +// Suppress duplicate fan-out when the upstream POSTer retries the same event id +// on a transient network error — without this, the same mission_completed (or +// any other event) is broadcast twice to SSE subscribers + downstream consumers. +const RECENT_EVENT_ID_TTL_MS = 5 * 60 * 1000; +const recentEventIds = new Map(); +function rememberRecentEventId(id: string, now: number): boolean { + const previous = recentEventIds.get(id); + if (typeof previous === 'number' && now - previous < RECENT_EVENT_ID_TTL_MS) return false; + recentEventIds.set(id, now); + if (recentEventIds.size > 1000) { + const cutoff = now - RECENT_EVENT_ID_TTL_MS; + recentEventIds.forEach((ts, key) => { if (ts < cutoff) recentEventIds.delete(key); }); + } + return true; +} + +// Resolve the EVENTS_ALLOWED_ORIGINS allowlist using the same env convention as +// mcp-auth's isOriginAllowed/allowedOriginsEnvVar('EVENTS_ALLOWED_ORIGINS'): +// prefer process.env when present, else fall back to the SvelteKit dynamic env. +function eventsAllowedOrigins(): string[] { + const raw = ( + Object.prototype.hasOwnProperty.call(process.env, 'EVENTS_ALLOWED_ORIGINS') + ? process.env.EVENTS_ALLOWED_ORIGINS + : env.EVENTS_ALLOWED_ORIGINS + ) || ''; + return raw + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +// CORS for the event bridge must not reflect an arbitrary origin. Loopback dev +// origins are always allowed (matches isOriginAllowed); any other origin must be +// explicitly listed in EVENTS_ALLOWED_ORIGINS, otherwise no CORS headers are sent. +function isCorsOriginAllowed(origin: string): boolean { + try { + if (hostedUiHostIsLoopback(new URL(origin).hostname)) return true; + } catch { + return false; + } + return eventsAllowedOrigins().includes(origin); +} + function corsHeaders(request: Request): Record { const origin = request.headers.get('origin'); - if (!origin) return {}; + if (!origin || !isCorsOriginAllowed(origin)) return {}; return { 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', @@ -397,6 +442,20 @@ export const POST: RequestHandler = async (event) => { return json({ error: 'Event type is required' }, { status: 400 }); } + // Short-circuit on caller-supplied id that's already been processed within + // the dedup window — keeps mission_completed (and every other event) from + // being fanned out twice when the upstream POSTer retries on a transient + // network error. Auto-generated ids fall through to the normal path. + const callerSuppliedId = + typeof payload.id === 'string' && payload.id.trim() ? payload.id.trim() : null; + if (callerSuppliedId && !rememberRecentEventId(callerSuppliedId, Date.now())) { + const headers = new Headers(); + for (const [key, value] of Object.entries(corsHeaders(event.request))) { + headers.set(key, value); + } + return json({ success: true, eventId: callerSuppliedId, deduplicated: true }, { headers }); + } + // Add metadata let fullEvent = { ...payload, @@ -522,7 +581,16 @@ export const POST: RequestHandler = async (event) => { for (const [key, value] of Object.entries(corsHeaders(event.request))) { headers.set(key, value); } - return json({ success: true, eventId: fullEvent.id }, { headers }); + // boardEligible tells the caller whether their event will surface on the + // Mission Control board views (/kanban, /trace, etc.). When false, the event + // is still broadcast over SSE but is silently dropped by the board's + // persistence layer because the missionId doesn't match the required shape. + // This flag lets callers detect the mismatch at emit time instead of + // discovering an empty board hours later. + const boardEligible = typeof fullEvent.missionId === 'string' + ? isMissionControlMissionId(fullEvent.missionId) + : false; + return json({ success: true, eventId: fullEvent.id, boardEligible }, { headers }); } catch (error) { console.error('[EventBridge] Error processing event:', error); return json({ error: 'Invalid event data' }, { status: 400, headers: corsHeaders(event.request) }); diff --git a/src/routes/api/events/events.auth.test.ts b/src/routes/api/events/events.auth.test.ts index 30e7bfef..4e3d243c 100644 --- a/src/routes/api/events/events.auth.test.ts +++ b/src/routes/api/events/events.auth.test.ts @@ -16,7 +16,9 @@ vi.mock('$env/dynamic/private', () => ({ })); vi.mock('$lib/server/mission-control-relay', () => ({ - relayMissionControlEvent: vi.fn() + relayMissionControlEvent: vi.fn(), + isMissionControlMissionId: (value: unknown): value is string => + typeof value === 'string' && /^(spark|mission)-[A-Za-z0-9_-]+$/.test(value.trim()) })); vi.mock('$lib/server/provider-runtime', () => ({ diff --git a/src/routes/api/mission/active/+server.ts b/src/routes/api/mission/active/+server.ts index 794859f7..bb85a39d 100644 --- a/src/routes/api/mission/active/+server.ts +++ b/src/routes/api/mission/active/+server.ts @@ -316,8 +316,7 @@ export const POST: RequestHandler = async (event) => { return json({ success: true, - message: 'Mission state saved', - path: missionPath + message: 'Mission state saved' }); } catch (error) { console.error('Failed to save active mission:', error); diff --git a/src/routes/api/prd-bridge/load-to-canvas/+server.ts b/src/routes/api/prd-bridge/load-to-canvas/+server.ts index 48a74ddb..edc21a44 100644 --- a/src/routes/api/prd-bridge/load-to-canvas/+server.ts +++ b/src/routes/api/prd-bridge/load-to-canvas/+server.ts @@ -546,7 +546,14 @@ export const POST: RequestHandler = async (event) => { await writeFileAtomic(pendingRequestFile, updatedPendingRequest); } } - void relayMissionControlEvent({ + // Guard against duplicate lifecycle relays when the upstream caller + // (Telegram bot, MCP) retries this POST after a transient network error. + // pendingRequestMeta.status === 'canvas_loaded' means a prior successful + // call already fired these relays; skipping them here keeps the + // operator-facing notifications (and downstream board entries) one-shot. + const alreadyLoaded = + !!pendingRequestMeta && pendingRequestMeta.status === 'canvas_loaded'; + if (!alreadyLoaded) void relayMissionControlEvent({ type: 'task_completed', missionId: resolvedMissionId, missionName: load.pipelineName, @@ -562,7 +569,7 @@ export const POST: RequestHandler = async (event) => { buildModeReason } }); - void relayMissionControlEvent({ + if (!alreadyLoaded) void relayMissionControlEvent({ type: 'mission_created', missionId: resolvedMissionId, missionName: load.pipelineName, diff --git a/src/routes/api/prd-bridge/write/+server.ts b/src/routes/api/prd-bridge/write/+server.ts index 2ace4523..113b5a31 100644 --- a/src/routes/api/prd-bridge/write/+server.ts +++ b/src/routes/api/prd-bridge/write/+server.ts @@ -9,8 +9,8 @@ import { logger } from '$lib/utils/logger'; import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { writeFile, mkdir, appendFile, readFile } from 'fs/promises'; -import { join } from 'path'; -import { existsSync } from 'fs'; +import { basename, dirname, join, resolve } from 'path'; +import { existsSync, realpathSync } from 'fs'; import { sparkAgentBridge } from '$lib/services/spark-agent-bridge'; import { enforceRateLimit, requireControlAuth } from '$lib/server/mcp-auth'; import { resolveCliBinary } from '$lib/server/cli-resolver'; @@ -24,6 +24,11 @@ import { formatTaskQualityGuidance } from '$lib/server/task-quality-rubric'; import { formatVerificationPlanGuidance, generateVerificationPlan } from '$lib/server/verification-plan-generator'; import { enrichBrief, isSparseUnderstandingClarification } from '$lib/server/brief-enricher'; import { spawnerStateDir } from '$lib/server/spawner-state'; +import { + externalProjectPathsAllowed, + isWithinDirectory, + sparkWorkspaceRoot +} from '$lib/server/spark-run-workspace'; import { writeFileAtomic } from '$lib/server/atomic-write'; import { projectStoredPrdAnalysisResultForTier, @@ -411,8 +416,66 @@ function slugifyTaskId(value: string, fallback: string): string { return slug || fallback; } +// Resolve through symlinks so containment comparisons are stable across platforms (e.g. +// macOS exposes os.tmpdir() as /var/folders/... which is a symlink to /private/var/...). +// The target folder usually does not exist yet, so we canonicalize the nearest existing +// ancestor and re-append the missing trailing segments — otherwise a not-yet-created +// target would compare as /var/... against a /private/var/... realpath'd root. +function canonicalize(candidatePath: string): string { + const absolute = resolve(candidatePath); + const missing: string[] = []; + let cursor = absolute; + while (!existsSync(cursor)) { + const parent = dirname(cursor); + if (parent === cursor) return absolute; + missing.unshift(basename(cursor)); + cursor = parent; + } + try { + return resolve(realpathSync(cursor), ...missing); + } catch { + return absolute; + } +} + +// A target folder is in-workspace when it sits under one of the Spark-controlled roots: +// the configured Spark workspace root or the spawner state directory. We intentionally do +// NOT use process.cwd() (the SvelteKit app root) — the spawner writes generated projects +// and static proofs under its own state/workspace tree, not under the server source tree. +// The SPARK_ALLOW_EXTERNAL_PROJECT_PATHS escape hatch keeps trusted local-dev flows working. +function isPathWithinWorkspace(candidatePath: string): boolean { + if (externalProjectPathsAllowed()) return true; + const resolvedCandidate = canonicalize(candidatePath); + const allowedRoots = [sparkWorkspaceRoot(), spawnerStateDir()] + .filter((root): root is string => Boolean(root && root.trim())) + .map((root) => canonicalize(root)); + return allowedRoots.some( + (root) => resolvedCandidate === root || isWithinDirectory(root, resolvedCandidate) + ); +} + +// A Windows drive-letter path (C:\...) on a POSIX host — or a POSIX-absolute path on a +// Windows host — denotes the user's own machine build location, not a path on this server. +// It cannot reference a server file (resolve() would mangle it under cwd), so it is preserved +// verbatim as the declared OS target rather than run through the server workspace guard, which +// matches inferProjectPathFromPrdLoad's treatment of explicit project paths. +function isForeignOsAbsolutePath(candidate: string): boolean { + const looksWindows = /^[A-Za-z]:[\\/]/.test(candidate); + const looksPosix = candidate.startsWith('/'); + return process.platform === 'win32' ? looksPosix : looksWindows; +} + function extractTargetFolder(content: string): string | null { - return extractExplicitProjectPath(content); + const candidate = extractExplicitProjectPath(content); + if (!candidate) return null; + // Explicit foreign-OS target paths are the user's declared build location; keep them as-is. + if (isForeignOsAbsolutePath(candidate)) return candidate; + const resolved = resolve(candidate); + if (!isPathWithinWorkspace(resolved)) { + logger.warn('[prd-bridge-write] Rejected target folder outside workspace', { candidate, resolved }); + return null; + } + return resolved; } function extractRequestedFiles(content: string): string[] { diff --git a/src/routes/preview/[token]/+server.ts b/src/routes/preview/[token]/+server.ts index fa71cf56..d9dd2c3d 100644 --- a/src/routes/preview/[token]/+server.ts +++ b/src/routes/preview/[token]/+server.ts @@ -20,8 +20,7 @@ export const GET: RequestHandler = async ({ params, url }) => { { headers: { 'content-type': contentType, - 'cache-control': 'no-store', - 'x-spark-preview-root': asset.projectRoot + 'cache-control': 'no-store' } } ); diff --git a/src/routes/preview/[token]/[...asset]/+server.ts b/src/routes/preview/[token]/[...asset]/+server.ts index a71e25b7..ee1dabd5 100644 --- a/src/routes/preview/[token]/[...asset]/+server.ts +++ b/src/routes/preview/[token]/[...asset]/+server.ts @@ -24,8 +24,7 @@ export const GET: RequestHandler = async ({ params, url }) => { { headers: { 'content-type': contentType, - 'cache-control': 'no-store', - 'x-spark-preview-root': asset.projectRoot + 'cache-control': 'no-store' } } );