Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/lib/components/MissionBoard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/lib/server/access-execution-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
}
Expand Down
12 changes: 9 additions & 3 deletions src/lib/server/brief-enricher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 20 additions & 2 deletions src/lib/server/command-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
Expand All @@ -168,6 +182,7 @@ export function runCommand(
});

child.on('close', (code) => {
clearTimeout(sigkillTimer);
if (!resolved) {
resolved = true;
res({
Expand All @@ -180,6 +195,7 @@ export function runCommand(
});

child.on('error', (err) => {
clearTimeout(sigkillTimer);
if (!resolved) {
resolved = true;
res({
Expand Down Expand Up @@ -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
}
}
Expand All @@ -238,7 +255,8 @@ export async function hasTestScript(projectPath: string): Promise<boolean> {
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;
}
}
Expand Down
53 changes: 52 additions & 1 deletion src/lib/server/creator-mission.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -1651,6 +1660,10 @@ export async function readCreatorMissionTrace(
): Promise<CreatorMissionTrace | null> {
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'));
Expand Down Expand Up @@ -1727,6 +1740,26 @@ 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 arbitrary code from the argument string.
// A command like `python -c "os.system('rm -rf /')"` would pass the allowlist
// because only the executable name is checked. These flags must be rejected.
// Covers node (-e/--eval, -p/--print), python (-c/--command, -m/--module) and the
// bare `-` stdin-program form. This list must include every interpreter flag that
// evaluates an argument or stdin rather than a script file on disk.
const DANGEROUS_INTERPRETER_FLAGS = new Set([
'-c',
'--command',
'-e',
'--eval',
'-p',
'--print',
'-m',
'--module',
'-'
]);

function splitCommandLine(command: string): string[] {
const parts: string[] = [];
let current = '';
Expand Down Expand Up @@ -1966,6 +1999,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 });
Expand Down
12 changes: 10 additions & 2 deletions src/lib/server/hosted-ui-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,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()): {
Expand Down
58 changes: 55 additions & 3 deletions src/lib/server/mission-control-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand Down Expand Up @@ -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<string, string>();
const taskLifecycleStates = new Map<string, string>();

function setBoundedLifecycle(map: Map<string, string>, 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';
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down
15 changes: 13 additions & 2 deletions src/lib/server/provider-clients/retry-after.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
// 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();
if (!trimmed) return fallbackMs;

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;
Expand Down
21 changes: 20 additions & 1 deletion src/lib/server/provider-clients/spark-harness-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderResult> {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading