diff --git a/package.json b/package.json index 299fa93..297e645 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "node": ">=24 <25" }, "scripts": { - "build": "tsc -p tsconfig.build.json", + "build": "tsc -p tsconfig.build.json && node scripts/copy-schemas.mjs && node scripts/copy-build-assets.mjs", "check": "npm run typecheck && npm test && npm run check:python", "check:python": "python3 -m unittest discover -s python/tests -v && python3 -m py_compile python/modeling_agent/*.py python/tests/*.py", "cli": "tsx src/cli/main.ts", diff --git a/scripts/copy-build-assets.mjs b/scripts/copy-build-assets.mjs new file mode 100644 index 0000000..a4d1f00 --- /dev/null +++ b/scripts/copy-build-assets.mjs @@ -0,0 +1,20 @@ +import { cp, mkdir, rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const dist = resolve(root, "dist"); +const pythonSource = resolve(root, "python"); +const pythonDestination = resolve(dist, "python"); +const pythonPackageDestination = resolve(pythonDestination, "modeling_agent"); +const pythonModules = ["__init__.py", "forecasting.py", "io.py", "metrics.py", "runner.py"]; + +// Compiled workers and report generation resolve these assets relative to dist/src. +await rm(pythonDestination, { recursive: true, force: true }); +await mkdir(pythonPackageDestination, { recursive: true }); +await Promise.all([ + ...pythonModules.map((filename) => cp(resolve(pythonSource, "modeling_agent", filename), resolve(pythonPackageDestination, filename))), + cp(resolve(pythonSource, "requirements.lock"), resolve(pythonDestination, "requirements.lock")), + cp(resolve(pythonSource, "standalone.Dockerfile"), resolve(pythonDestination, "standalone.Dockerfile")), + cp(resolve(pythonSource, "standalone_reproduce.py"), resolve(pythonDestination, "standalone_reproduce.py")) +]); diff --git a/scripts/copy-schemas.mjs b/scripts/copy-schemas.mjs new file mode 100644 index 0000000..6ad2585 --- /dev/null +++ b/scripts/copy-schemas.mjs @@ -0,0 +1,10 @@ +import { cp, rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const source = resolve(root, "schemas"); +const destination = resolve(root, "dist", "schemas"); + +await rm(destination, { recursive: true, force: true }); +await cp(source, destination, { recursive: true }); diff --git a/src/contracts/schema-registry.ts b/src/contracts/schema-registry.ts index e315bf3..ea66899 100644 --- a/src/contracts/schema-registry.ts +++ b/src/contracts/schema-registry.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { Ajv2020 } from "ajv/dist/2020.js"; import type { ErrorObject, ValidateFunction } from "ajv"; @@ -22,6 +23,11 @@ const schemaFiles: Record = { "evidence-graph": "evidence-graph.v1.json" }; +function defaultSchemaDirectory(): string { + // This resolves to schemas/ for tsx and dist/schemas/ after compilation. + return fileURLToPath(new URL("../../schemas/", import.meta.url)); +} + export class ContractValidationError extends Error { readonly schemaName: SchemaName; readonly validationErrors: ErrorObject[]; @@ -43,7 +49,7 @@ export class SchemaRegistry { readonly #ajv: InstanceType; readonly #validators = new Map(); - constructor(schemaDirectory = fileURLToPath(new URL("../../schemas/", import.meta.url))) { + constructor(schemaDirectory = defaultSchemaDirectory()) { this.#ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true }); this.#ajv.addFormat("date-time", { type: "string", @@ -51,7 +57,7 @@ export class SchemaRegistry { }); for (const [name, filename] of Object.entries(schemaFiles) as Array<[SchemaName, string]>) { - const schema = JSON.parse(readFileSync(new URL(filename, `file://${schemaDirectory}/`), "utf8")) as object; + const schema = JSON.parse(readFileSync(resolve(schemaDirectory, filename), "utf8")) as object; this.#validators.set(name, this.#ajv.compile(schema)); } } diff --git a/src/runtime/factory.ts b/src/runtime/factory.ts index b8524c2..d9de48c 100644 --- a/src/runtime/factory.ts +++ b/src/runtime/factory.ts @@ -1,8 +1,10 @@ -type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; +import { SchemaRegistry } from "../contracts/schema-registry.js"; import { FakeRuntimeAdapter } from "./fake-runtime.js"; import { PiRuntimeAdapter } from "./pi-runtime.js"; import type { AgentRuntime } from "./types.js"; +type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; + export interface RuntimeFactoryOptions { kind: "fake" | "pi"; provider?: string; @@ -10,11 +12,11 @@ export interface RuntimeFactoryOptions { thinkingLevel?: ThinkingLevel; } -export function createRuntime(options: RuntimeFactoryOptions): AgentRuntime { - if (options.kind === "fake") return new FakeRuntimeAdapter(); +export function createRuntime(options: RuntimeFactoryOptions, schemas = new SchemaRegistry()): AgentRuntime { + if (options.kind === "fake") return new FakeRuntimeAdapter(schemas); return new PiRuntimeAdapter({ ...(options.provider ? { provider: options.provider } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinkingLevel ? { thinkingLevel: options.thinkingLevel } : {}) - }); + }, schemas); } diff --git a/src/server/config.ts b/src/server/config.ts new file mode 100644 index 0000000..968d3b1 --- /dev/null +++ b/src/server/config.ts @@ -0,0 +1,22 @@ +export const DEFAULT_SERVER_HOST = "127.0.0.1"; +export const DEFAULT_SERVER_PORT = 4317; + +export interface ServerConfig { + host: string; + port: number; + runsRoot: string; +} + +export function readServerConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { + const host = env.MODELING_AGENT_HOST ?? DEFAULT_SERVER_HOST; + const rawPort = env.MODELING_AGENT_PORT; + const port = rawPort === undefined ? DEFAULT_SERVER_PORT : Number(rawPort); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error("MODELING_AGENT_PORT must be an integer between 1 and 65535."); + } + return { + host, + port, + runsRoot: env.MODELING_AGENT_RUNS_ROOT ?? "runs" + }; +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..32b1e72 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,404 @@ +import { constants as fsConstants } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { open, realpath, type FileHandle } from "node:fs/promises"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import Fastify, { + type FastifyInstance, + type FastifyReply, + type FastifyRequest +} from "fastify"; +import type { RunEvent, RunStatus, RunSummary } from "../contracts/types.js"; +import type { RunDetails, RunOptions, RunResult } from "../orchestrator/orchestrator.js"; + +const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const; +const COMPLETED_STATUSES = new Set(["completed", "completed_with_warnings"]); +const RUN_FAILURE_MESSAGE = "The run failed. Inspect local run artifacts for details."; + +export interface ServerRunResult { + run: Pick; +} + +export interface OrchestratorAdapter { + listRuns(): RunSummary[] | Promise; + showRun(id: string): RunDetails | undefined | Promise; + run(packagePath: string, options: RunOptions): Promise; + close?(): void | Promise; +} + +export interface BuildServerOptions { + orchestrator: OrchestratorAdapter; + host?: string; + clock?: () => Date; + jobId?: () => string; + logger?: boolean; +} + +interface CreateRunBody { + packagePath: string; + runtime?: "fake" | "pi"; + execution?: "local" | "docker"; + provider?: string; + model?: string; + thinking?: (typeof THINKING_LEVELS)[number]; +} + +interface RunParams { + id: string; +} + +type JobStatus = "queued" | "running" | "succeeded" | "failed"; + +interface JobRecord { + id: string; + status: JobStatus; + createdAt: string; + updatedAt: string; + runId?: string; + error?: ErrorBody; +} + +interface ErrorBody { + class: string; + message: string; +} + +interface PublicRun { + id: string; + runtimeKind: RunSummary["runtime_kind"]; + executionKind: RunSummary["execution_kind"]; + status: RunStatus; + currentStage: string; + createdAt: string; + updatedAt: string; + archiveAvailable: boolean; + error?: ErrorBody; +} + +interface PublicEvent { + id?: number; + stageId?: string; + attemptId?: string; + eventType: string; + timestamp: string; +} + +class HttpError extends Error { + readonly statusCode: number; + readonly errorClass: string; + + constructor(statusCode: number, errorClass: string, message: string) { + super(message); + this.name = "HttpError"; + this.statusCode = statusCode; + this.errorClass = errorClass; + } +} + +const createRunSchema = { + type: "object", + additionalProperties: false, + required: ["packagePath"], + properties: { + packagePath: { type: "string", minLength: 1, maxLength: 4096 }, + runtime: { type: "string", enum: ["fake", "pi"] }, + execution: { type: "string", enum: ["local", "docker"] }, + provider: { type: "string", minLength: 1, maxLength: 128, pattern: "^[^\\u0000-\\u001F\\u007F]+$" }, + model: { type: "string", minLength: 1, maxLength: 256, pattern: "^[^\\u0000-\\u001F\\u007F]+$" }, + thinking: { type: "string", enum: THINKING_LEVELS } + } +} as const; + +const runParamsSchema = { + type: "object", + additionalProperties: false, + required: ["id"], + properties: { + id: { type: "string", minLength: 1, maxLength: 256, pattern: "^[A-Za-z0-9._-]+$" } + } +} as const; + +function isLoopbackHost(host: string): boolean { + const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, ""); + if (normalized === "localhost" || normalized === "::1") return true; + const match = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(normalized); + return match !== null && match.slice(1).every((part) => Number(part) <= 255); +} + +function errorEnvelope(errorClass: string, message: string): { status: "failed"; error: ErrorBody } { + return { status: "failed", error: { class: errorClass, message } }; +} + +function isUnknownRunError(error: unknown): boolean { + return error instanceof Error && /^Unknown run(?::|\b)/i.test(error.message); +} + +async function getRun(orchestrator: OrchestratorAdapter, id: string): Promise { + try { + return await orchestrator.showRun(id); + } catch (error) { + if (isUnknownRunError(error)) return undefined; + throw error; + } +} + +function publicRun(run: RunSummary): PublicRun { + const result: PublicRun = { + id: run.id, + runtimeKind: run.runtime_kind, + executionKind: run.execution_kind, + status: run.status, + currentStage: run.current_stage, + createdAt: run.created_at, + updatedAt: run.updated_at, + archiveAvailable: COMPLETED_STATUSES.has(run.status) && run.project_archive !== null + }; + if (run.status === "failed") result.error = { class: "run_failure", message: RUN_FAILURE_MESSAGE }; + return result; +} + +function publicEvent(event: RunEvent): PublicEvent { + return { + ...(event.id === undefined ? {} : { id: event.id }), + ...(event.stage_id === undefined ? {} : { stageId: event.stage_id }), + ...(event.attempt_id === undefined ? {} : { attemptId: event.attempt_id }), + eventType: event.event_type, + timestamp: event.timestamp + }; +} + +function publicJob(job: JobRecord): JobRecord { + return { + id: job.id, + status: job.status, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + ...(job.runId === undefined ? {} : { runId: job.runId }), + ...(job.error === undefined ? {} : { error: job.error }) + }; +} + +function runOptions(body: CreateRunBody): RunOptions { + return { + runtimeKind: body.runtime ?? "fake", + executionKind: body.execution ?? "local", + ...(body.provider === undefined ? {} : { provider: body.provider }), + ...(body.model === undefined ? {} : { model: body.model }), + ...(body.thinking === undefined ? {} : { thinkingLevel: body.thinking }) + }; +} + +const ARCHIVE_GONE_MESSAGE = "The project archive is no longer available."; +const ARCHIVE_UNAVAILABLE_CODES = new Set(["EACCES", "ELOOP", "ENOENT", "ENOTDIR", "EPERM"]); + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + return typeof error.code === "string" ? error.code : undefined; +} + +function isArchiveUnavailable(error: unknown): boolean { + const code = errorCode(error); + return code !== undefined && ARCHIVE_UNAVAILABLE_CODES.has(code); +} + +function isWithin(root: string, candidate: string): boolean { + const candidateRelative = relative(root, candidate); + return candidateRelative === "" + || (!isAbsolute(candidateRelative) && candidateRelative !== ".." && !candidateRelative.startsWith(`..${sep}`)); +} + +function archiveGone(): HttpError { + return new HttpError(410, "archive_gone", ARCHIVE_GONE_MESSAGE); +} + +function statusCode(error: unknown): number | undefined { + if (typeof error !== "object" || error === null || !("statusCode" in error)) return undefined; + return typeof error.statusCode === "number" ? error.statusCode : undefined; +} + +function hasZipSignature(signature: Buffer, bytesRead: number): boolean { + if (bytesRead < 4 || signature[0] !== 0x50 || signature[1] !== 0x4b) return false; + return (signature[2] === 0x03 && signature[3] === 0x04) + || (signature[2] === 0x05 && signature[3] === 0x06) + || (signature[2] === 0x07 && signature[3] === 0x08); +} + +interface OpenArchive { + handle: FileHandle; + size: number; +} + +async function openArchive(workspacePath: string, archivePath: string): Promise { + const workspaceRoot = resolve(workspacePath); + const candidatePath = resolve(archivePath); + if (!isWithin(workspaceRoot, candidatePath) || candidatePath === workspaceRoot) throw archiveGone(); + + let canonicalWorkspace: string; + try { + canonicalWorkspace = await realpath(workspaceRoot); + } catch (error) { + if (isArchiveUnavailable(error)) throw archiveGone(); + throw error; + } + + let workspaceHandle: FileHandle | undefined; + let archiveHandle: FileHandle | undefined; + try { + workspaceHandle = await open( + workspaceRoot, + fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW + ); + const openedWorkspace = await realpath(`/proc/self/fd/${workspaceHandle.fd}`); + const workspaceStat = await workspaceHandle.stat({ bigint: true }); + if (openedWorkspace !== canonicalWorkspace || !workspaceStat.isDirectory()) throw archiveGone(); + + const archiveRelative = relative(workspaceRoot, candidatePath); + archiveHandle = await open( + `/proc/self/fd/${workspaceHandle.fd}/${archiveRelative}`, + fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK + ); + const canonicalArchive = await realpath(`/proc/self/fd/${archiveHandle.fd}`); + if (!isWithin(canonicalWorkspace, canonicalArchive) || canonicalArchive === canonicalWorkspace) { + throw archiveGone(); + } + + const archiveStat = await archiveHandle.stat(); + const stableWorkspaceStat = await workspaceHandle.stat({ bigint: true }); + if (workspaceStat.dev !== stableWorkspaceStat.dev || workspaceStat.ino !== stableWorkspaceStat.ino) { + throw archiveGone(); + } + if (!archiveStat.isFile()) throw archiveGone(); + const signature = Buffer.alloc(4); + const { bytesRead } = await archiveHandle.read(signature, 0, signature.length, 0); + if (!hasZipSignature(signature, bytesRead)) throw archiveGone(); + + const result = { handle: archiveHandle, size: archiveStat.size }; + archiveHandle = undefined; + return result; + } catch (error) { + if (error instanceof HttpError) throw error; + if (isArchiveUnavailable(error)) throw archiveGone(); + throw error; + } finally { + await archiveHandle?.close().catch(() => undefined); + await workspaceHandle?.close().catch(() => undefined); + } +} + +export function buildServer(options: BuildServerOptions): FastifyInstance { + const host = options.host ?? "127.0.0.1"; + if (!isLoopbackHost(host)) { + throw new Error(`Refusing unauthenticated non-loopback host: ${host}`); + } + + const clock = options.clock ?? (() => new Date()); + const nextJobId = options.jobId ?? (() => `job-${randomUUID()}`); + const jobs = new Map(); + const activeRuns = new Set>(); + const server = Fastify({ + logger: options.logger ?? false, + ajv: { + customOptions: { coerceTypes: false, removeAdditional: false } + } + }); + + server.setErrorHandler((error, request, reply) => { + if ((typeof error === "object" && error !== null && "validation" in error && error.validation) || statusCode(error) === 400) { + void reply.status(400).send(errorEnvelope("invalid_request", "Request validation failed.")); + return; + } + if (statusCode(error) === 415) { + void reply.status(415).send(errorEnvelope("unsupported_media_type", "Content-Type must be application/json.")); + return; + } + if (error instanceof HttpError) { + void reply.status(error.statusCode).send(errorEnvelope(error.errorClass, error.message)); + return; + } + request.log.error({ err: error }, "Request failed"); + void reply.status(500).send(errorEnvelope("internal_error", "The server could not complete the request.")); + }); + + server.setNotFoundHandler((_request, reply) => reply + .status(404) + .send(errorEnvelope("route_not_found", "Route not found."))); + + server.get("/health", async () => ({ status: "ok" })); + + server.get("/api/runs", async () => ({ + status: "ok", + runs: (await options.orchestrator.listRuns()).map(publicRun) + })); + + server.get<{ Params: RunParams }>("/api/runs/:id", { schema: { params: runParamsSchema } }, async (request) => { + const details = await getRun(options.orchestrator, request.params.id); + if (!details) throw new HttpError(404, "run_not_found", "Run not found."); + return { + status: "ok", + run: publicRun(details.run), + events: details.events.map(publicEvent) + }; + }); + + server.post<{ Body: CreateRunBody }>("/api/runs", { schema: { body: createRunSchema } }, async (request, reply) => { + const id = nextJobId(); + if (jobs.has(id)) { + throw new HttpError(409, "job_id_conflict", "A job with the generated id already exists."); + } + const timestamp = clock().toISOString(); + const job: JobRecord = { id, status: "queued", createdAt: timestamp, updatedAt: timestamp }; + jobs.set(id, job); + + let task!: Promise; + task = Promise.resolve().then(async () => { + job.status = "running"; + job.updatedAt = clock().toISOString(); + try { + const result = await options.orchestrator.run(request.body.packagePath, runOptions(request.body)); + job.status = "succeeded"; + job.runId = result.run.id; + job.updatedAt = clock().toISOString(); + } catch { + job.status = "failed"; + job.error = { class: "run_failure", message: RUN_FAILURE_MESSAGE }; + job.updatedAt = clock().toISOString(); + } + }).finally(() => { + activeRuns.delete(task); + }); + activeRuns.add(task); + + return reply.status(202).send({ status: "accepted", job: publicJob(job) }); + }); + + server.get<{ Params: RunParams }>("/api/jobs/:id", { schema: { params: runParamsSchema } }, async (request) => { + const job = jobs.get(request.params.id); + if (!job) throw new HttpError(404, "job_not_found", "Job not found."); + return { status: "ok", job: publicJob(job) }; + }); + + server.get<{ Params: RunParams }>("/api/runs/:id/archive", { schema: { params: runParamsSchema } }, async (request, reply) => { + const details = await getRun(options.orchestrator, request.params.id); + if (!details) throw new HttpError(404, "run_not_found", "Run not found."); + const archive = details.run.project_archive; + if (!COMPLETED_STATUSES.has(details.run.status) || archive === null) { + throw new HttpError(409, "archive_not_ready", "The run does not have a completed project archive."); + } + const openedArchive = await openArchive(details.run.workspace_path, archive); + const archiveStream = openedArchive.handle.createReadStream({ autoClose: true, start: 0 }); + const filename = "project.zip"; + return reply + .type("application/zip") + .header("Content-Disposition", `attachment; filename="${filename}"`) + .header("Content-Length", String(openedArchive.size)) + .send(archiveStream); + }); + + server.addHook("onClose", async () => { + await Promise.allSettled([...activeRuns]); + await options.orchestrator.close?.(); + }); + + return server; +} + +export type { FastifyInstance, FastifyReply, FastifyRequest }; diff --git a/src/server/main.ts b/src/server/main.ts new file mode 100644 index 0000000..3f4058f --- /dev/null +++ b/src/server/main.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env node +import { resolve } from "node:path"; +import { SchemaRegistry } from "../contracts/schema-registry.js"; +import { Orchestrator } from "../orchestrator/orchestrator.js"; +import { createRuntime } from "../runtime/factory.js"; +import { readServerConfig } from "./config.js"; +import { buildServer } from "./index.js"; + +async function main(): Promise { + const config = readServerConfig(); + const schemas = new SchemaRegistry(); + const server = buildServer({ + host: config.host, + logger: true, + orchestrator: new Orchestrator({ + runsRoot: resolve(config.runsRoot), + schemas, + runtimeFactory: (options) => createRuntime(options, schemas) + }) + }); + let closing: Promise | undefined; + const shutdown = (signal: NodeJS.Signals): void => { + if (closing) return; + server.log.info({ signal }, "Shutting down"); + closing = server.close().then(() => undefined); + void closing.catch((error: unknown) => { + server.log.error({ err: error }, "Graceful shutdown failed"); + process.exitCode = 1; + }); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + await server.listen({ host: config.host, port: config.port }); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`${JSON.stringify({ status: "failed", error: { class: "server_startup_failure", message } })}\n`); + process.exitCode = 1; +}); diff --git a/tests/server-api.test.ts b/tests/server-api.test.ts new file mode 100644 index 0000000..6ff0d22 --- /dev/null +++ b/tests/server-api.test.ts @@ -0,0 +1,522 @@ +import { lstat, mkdir, mkdtemp, readlink, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { FastifyInstance } from "fastify"; +import type { InjectPayload } from "light-my-request"; +import type { RunDetails, RunOptions } from "../src/orchestrator/orchestrator.js"; +import type { RunEvent, RunSummary } from "../src/contracts/types.js"; +import { + buildServer, + type OrchestratorAdapter, + type ServerRunResult +} from "../src/server/index.js"; + +const fixedNow = new Date("2026-08-12T18:30:00.000Z"); +const servers: FastifyInstance[] = []; + +function summary(overrides: Partial = {}): RunSummary { + return { + id: "run-20260812183000-abcdef12", + package_path: "/home/private/competition-package", + workspace_path: "/home/private/runs/workspaces/run-20260812183000-abcdef12", + runtime_kind: "fake", + execution_kind: "local", + status: "completed", + current_stage: "export", + created_at: "2026-08-12T18:29:00.000Z", + updated_at: "2026-08-12T18:30:00.000Z", + error_message: null, + project_archive: "/home/private/runs/workspaces/run-20260812183000-abcdef12/project.zip", + ...overrides + }; +} + +function event(overrides: Partial = {}): RunEvent { + return { + id: 1, + run_id: "run-20260812183000-abcdef12", + stage_id: "export", + event_type: "run_completed", + timestamp: "2026-08-12T18:30:00.000Z", + payload: { + project_archive: "/home/private/runs/workspaces/run/project.zip", + stack: "Error: secret\n at /home/private/src/file.ts:1:1", + token: "top-secret-token" + }, + ...overrides + }; +} + +interface AdapterOverrides { + listRuns?: () => RunSummary[] | Promise; + showRun?: (id: string) => RunDetails | undefined | Promise; + run?: (packagePath: string, options: RunOptions) => Promise; + close?: () => void | Promise; +} + +function adapter(overrides: AdapterOverrides = {}): OrchestratorAdapter { + return { + listRuns: overrides.listRuns ?? (() => []), + showRun: overrides.showRun ?? ((id: string) => { + throw new Error(`Unknown run: ${id}`); + }), + run: overrides.run ?? (async () => ({ run: summary() })), + ...(overrides.close ? { close: overrides.close } : {}) + }; +} + +function server(orchestrator: OrchestratorAdapter, options: Parameters[0] = { orchestrator }): FastifyInstance { + const instance = buildServer({ + ...options, + orchestrator, + clock: () => fixedNow, + jobId: () => "job-fixed" + }); + servers.push(instance); + return instance; +} + +async function waitForJob(instance: FastifyInstance, id: string, expected: string): Promise> { + for (let attempt = 0; attempt < 100; attempt += 1) { + const response = await instance.inject({ method: "GET", url: `/api/jobs/${id}` }); + const body = response.json() as { job?: Record }; + if (body.job?.status === expected) return body.job; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 0)); + } + throw new Error(`Job ${id} did not reach ${expected}.`); +} + +afterEach(async () => { + await Promise.allSettled(servers.splice(0).map(async (instance) => instance.close())); +}); + +describe("Fastify server baseline", () => { + it("reports health without enabling CORS", async () => { + const instance = server(adapter()); + const response = await instance.inject({ + method: "GET", + url: "/health", + headers: { origin: "https://untrusted.example" } + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ status: "ok" }); + expect(response.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + it.each(["0.0.0.0", "::", "192.168.1.9", "example.com"])("rejects unauthenticated non-loopback host %s", (host) => { + expect(() => buildServer({ orchestrator: adapter(), host })).toThrow(/loopback/i); + }); + + it.each(["127.0.0.1", "127.0.0.2", "::1", "localhost"])("accepts loopback host %s", async (host) => { + const instance = buildServer({ orchestrator: adapter(), host }); + servers.push(instance); + expect((await instance.inject({ method: "GET", url: "/health" })).statusCode).toBe(200); + }); + + it("strictly rejects malformed run requests and unknown fields", async () => { + const run = vi.fn(async () => ({ run: summary() })); + const instance = server(adapter({ run })); + const invalidBodies: InjectPayload[] = [ + {}, + { packagePath: 42 }, + { packagePath: "/package", unknown: true }, + { packagePath: "/package", runtime: "other" }, + { packagePath: "/package", execution: "remote" }, + { packagePath: "/package", provider: "p".repeat(129) }, + { packagePath: "/package", model: "m".repeat(257) }, + { packagePath: "/package", thinking: "extreme" } + ]; + + for (const body of invalidBodies) { + const response = await instance.inject({ method: "POST", url: "/api/runs", payload: body }); + expect(response.statusCode, JSON.stringify(body)).toBe(400); + expect(response.json()).toEqual({ + status: "failed", + error: { class: "invalid_request", message: "Request validation failed." } + }); + } + + const malformed = await instance.inject({ + method: "POST", + url: "/api/runs", + headers: { "content-type": "application/json" }, + payload: "{not-json" + }); + expect(malformed.statusCode).toBe(400); + expect(malformed.json()).toEqual({ + status: "failed", + error: { class: "invalid_request", message: "Request validation failed." } + }); + + const unsupported = await instance.inject({ + method: "POST", + url: "/api/runs", + headers: { "content-type": "application/octet-stream" }, + payload: Buffer.from("package") + }); + expect(unsupported.statusCode).toBe(415); + expect(unsupported.json()).toEqual({ + status: "failed", + error: { class: "unsupported_media_type", message: "Content-Type must be application/json." } + }); + expect(run).not.toHaveBeenCalled(); + }); + + it("returns 202 immediately, applies defaults, and tracks a successful background job", async () => { + let finish: ((value: ServerRunResult) => void) | undefined; + const pending = new Promise((resolvePromise) => { finish = resolvePromise; }); + const run = vi.fn(() => pending); + const instance = server(adapter({ run })); + + const response = await instance.inject({ + method: "POST", + url: "/api/runs", + payload: { packagePath: "/adapter/validates/this" } + }); + + expect(response.statusCode).toBe(202); + expect(response.json()).toEqual({ + status: "accepted", + job: { + id: "job-fixed", + status: "queued", + createdAt: fixedNow.toISOString(), + updatedAt: fixedNow.toISOString() + } + }); + expect(response.body).not.toContain("/adapter/validates/this"); + await waitForJob(instance, "job-fixed", "running"); + expect(run).toHaveBeenCalledWith("/adapter/validates/this", { + runtimeKind: "fake", + executionKind: "local" + }); + + finish?.({ run: summary({ id: "run-success" }) }); + expect(await waitForJob(instance, "job-fixed", "succeeded")).toEqual({ + id: "job-fixed", + status: "succeeded", + createdAt: fixedNow.toISOString(), + updatedAt: fixedNow.toISOString(), + runId: "run-success" + }); + }); + + it("passes bounded optional runtime settings to the adapter", async () => { + const run = vi.fn(async () => ({ run: summary({ id: "run-pi" }) })); + const instance = server(adapter({ run })); + + const response = await instance.inject({ + method: "POST", + url: "/api/runs", + payload: { + packagePath: "relative-package", + runtime: "pi", + execution: "docker", + provider: "anthropic", + model: "claude-model", + thinking: "high" + } + }); + + expect(response.statusCode).toBe(202); + await waitForJob(instance, "job-fixed", "succeeded"); + expect(run).toHaveBeenCalledWith("relative-package", { + runtimeKind: "pi", + executionKind: "docker", + provider: "anthropic", + model: "claude-model", + thinkingLevel: "high" + }); + }); + + it("atomically rejects a duplicate generated job id under concurrent requests", async () => { + let finish: ((value: ServerRunResult) => void) | undefined; + const pending = new Promise((resolvePromise) => { finish = resolvePromise; }); + const run = vi.fn(() => pending); + const instance = server(adapter({ run })); + + const responses = await Promise.all([ + instance.inject({ method: "POST", url: "/api/runs", payload: { packagePath: "one" } }), + instance.inject({ method: "POST", url: "/api/runs", payload: { packagePath: "two" } }) + ]); + + expect(responses.map((response) => response.statusCode).sort()).toEqual([202, 409]); + const conflict = responses.find((response) => response.statusCode === 409); + expect(conflict?.json()).toEqual({ + status: "failed", + error: { class: "job_id_conflict", message: "A job with the generated id already exists." } + }); + expect(run).toHaveBeenCalledTimes(1); + finish?.({ run: summary() }); + await waitForJob(instance, "job-fixed", "succeeded"); + }); + + it("contains background rejections without leaking paths, stacks, tokens, or database details", async () => { + const instance = server(adapter({ + run: async () => { + throw new Error("token=secret failed at /home/private/run/runs.sqlite\nSTACK INTERNAL_ENV=value"); + } + })); + + expect((await instance.inject({ + method: "POST", + url: "/api/runs", + payload: { packagePath: "/home/private/package" } + })).statusCode).toBe(202); + + const failed = await waitForJob(instance, "job-fixed", "failed"); + expect(failed).toEqual({ + id: "job-fixed", + status: "failed", + createdAt: fixedNow.toISOString(), + updatedAt: fixedNow.toISOString(), + error: { class: "run_failure", message: "The run failed. Inspect local run artifacts for details." } + }); + expect(JSON.stringify(failed)).not.toMatch(/secret|token|stack|\/home\/|runs\.sqlite|INTERNAL_ENV/i); + }); + + it("returns stable 404 envelopes for unknown jobs and routes", async () => { + const instance = server(adapter()); + const response = await instance.inject({ method: "GET", url: "/api/jobs/missing-job" }); + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ + status: "failed", + error: { class: "job_not_found", message: "Job not found." } + }); + + const route = await instance.inject({ method: "GET", url: "/does-not-exist" }); + expect(route.statusCode).toBe(404); + expect(route.json()).toEqual({ + status: "failed", + error: { class: "route_not_found", message: "Route not found." } + }); + }); + + it("lists and shows only safe public run and event fields", async () => { + const run = summary({ status: "failed", error_message: "ENOENT /home/private/package token=secret" }); + const details = { run, events: [event()] }; + const instance = server(adapter({ + listRuns: () => [run], + showRun: (id) => id === run.id ? details : undefined + })); + + const listed = await instance.inject({ method: "GET", url: "/api/runs" }); + expect(listed.statusCode).toBe(200); + expect(listed.json()).toEqual({ + status: "ok", + runs: [{ + id: run.id, + runtimeKind: "fake", + executionKind: "local", + status: "failed", + currentStage: "export", + createdAt: run.created_at, + updatedAt: run.updated_at, + archiveAvailable: false, + error: { class: "run_failure", message: "The run failed. Inspect local run artifacts for details." } + }] + }); + + const shown = await instance.inject({ method: "GET", url: `/api/runs/${run.id}` }); + expect(shown.statusCode).toBe(200); + expect(shown.json()).toEqual({ + status: "ok", + run: (listed.json() as { runs: unknown[] }).runs[0], + events: [{ + id: 1, + stageId: "export", + eventType: "run_completed", + timestamp: "2026-08-12T18:30:00.000Z" + }] + }); + expect(`${listed.body}${shown.body}`).not.toMatch(/package_path|workspace_path|project_archive|error_message|\/home\/|token|stack|runs\.sqlite/i); + }); + + it("returns a stable 404 when the adapter cannot find a run", async () => { + const instance = server(adapter()); + const response = await instance.inject({ method: "GET", url: "/api/runs/run-missing" }); + expect(response.statusCode).toBe(404); + expect(response.json()).toEqual({ + status: "failed", + error: { class: "run_not_found", message: "Run not found." } + }); + }); + + it("gates archives by run state and file availability, then streams the verified archive", async () => { + const root = await mkdtemp(join(tmpdir(), "modeling-server-archive-")); + const archive = resolve(root, "project.zip"); + const invalidArchive = resolve(root, "invalid-project.zip"); + const missingArchive = resolve(root, "missing-project.zip"); + const bytes = Buffer.from("PK\u0003\u0004archive-bytes", "binary"); + await Promise.all([ + writeFile(archive, bytes), + writeFile(invalidArchive, "not a zip", "utf8") + ]); + const runs = new Map([ + ["run-running", { run: summary({ id: "run-running", status: "running", workspace_path: root, project_archive: archive }), events: [] }], + ["run-no-archive", { run: summary({ id: "run-no-archive", workspace_path: root, project_archive: null }), events: [] }], + ["run-gone", { run: summary({ id: "run-gone", workspace_path: root, project_archive: missingArchive }), events: [] }], + ["run-invalid", { run: summary({ id: "run-invalid", workspace_path: root, project_archive: invalidArchive }), events: [] }], + ["run-outside", { run: summary({ id: "run-outside", workspace_path: resolve(root, "other-workspace"), project_archive: archive }), events: [] }], + ["run-ready", { run: summary({ id: "run-ready", workspace_path: root, project_archive: archive }), events: [] }] + ]); + const instance = server(adapter({ showRun: (id) => runs.get(id) })); + + expect((await instance.inject({ method: "GET", url: "/api/runs/run-unknown/archive" })).statusCode).toBe(404); + const running = await instance.inject({ method: "GET", url: "/api/runs/run-running/archive" }); + expect(running.statusCode).toBe(409); + expect(running.json()).toEqual({ + status: "failed", + error: { class: "archive_not_ready", message: "The run does not have a completed project archive." } + }); + expect((await instance.inject({ method: "GET", url: "/api/runs/run-no-archive/archive" })).statusCode).toBe(409); + const gone = await instance.inject({ method: "GET", url: "/api/runs/run-gone/archive" }); + expect(gone.statusCode).toBe(410); + expect(gone.json()).toEqual({ + status: "failed", + error: { class: "archive_gone", message: "The project archive is no longer available." } + }); + + const invalid = await instance.inject({ method: "GET", url: "/api/runs/run-invalid/archive" }); + expect(invalid.statusCode).toBe(410); + expect(invalid.json()).toEqual({ + status: "failed", + error: { class: "archive_gone", message: "The project archive is no longer available." } + }); + + const outside = await instance.inject({ method: "GET", url: "/api/runs/run-outside/archive" }); + expect(outside.statusCode).toBe(410); + expect(outside.json()).toEqual({ + status: "failed", + error: { class: "archive_gone", message: "The project archive is no longer available." } + }); + expect(outside.rawPayload).not.toEqual(bytes); + + const traversal = await instance.inject({ method: "GET", url: "/api/runs/%2Fetc%2Fpasswd/archive" }); + expect(traversal.statusCode).toBe(400); + expect(traversal.json()).toEqual({ + status: "failed", + error: { class: "invalid_request", message: "Request validation failed." } + }); + + const ready = await instance.inject({ method: "GET", url: "/api/runs/run-ready/archive" }); + expect(ready.statusCode).toBe(200); + expect(ready.headers["content-type"]).toMatch(/^application\/zip/); + expect(ready.headers["content-disposition"]).toBe("attachment; filename=\"project.zip\""); + expect(ready.headers["content-length"]).toBe(String(bytes.length)); + expect(ready.rawPayload).toEqual(bytes); + + await rm(root, { recursive: true, force: true }); + }); + + it("rejects a final archive symlink without returning bytes from outside the workspace", async () => { + const root = await mkdtemp(join(tmpdir(), "modeling-server-archive-symlink-")); + try { + const workspace = resolve(root, "workspace"); + const outsideArchive = resolve(root, "outside.zip"); + const archive = resolve(workspace, "project.zip"); + const outsideBytes = Buffer.from("PK\u0003\u0004OUTSIDE-HOST-SECRET", "binary"); + await mkdir(workspace); + await writeFile(outsideArchive, outsideBytes); + await symlink(outsideArchive, archive); + const instance = server(adapter({ + showRun: (id) => id === "run-symlink" + ? { run: summary({ id, workspace_path: workspace, project_archive: archive }), events: [] } + : undefined + })); + + const response = await instance.inject({ method: "GET", url: "/api/runs/run-symlink/archive" }); + + expect(response.statusCode).toBe(410); + expect(response.json()).toEqual({ + status: "failed", + error: { class: "archive_gone", message: "The project archive is no longer available." } + }); + expect(response.rawPayload).not.toEqual(outsideBytes); + expect(response.body).not.toContain("OUTSIDE-HOST-SECRET"); + expect((await lstat(archive)).isSymbolicLink()).toBe(true); + expect(await readlink(archive)).toBe(outsideArchive); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects an archive reached through an intermediate symlink outside the workspace", async () => { + const root = await mkdtemp(join(tmpdir(), "modeling-server-archive-chain-")); + try { + const workspace = resolve(root, "workspace"); + const outsideDirectory = resolve(root, "outside"); + const outsideArchive = resolve(outsideDirectory, "project.zip"); + const archive = resolve(workspace, "link", "project.zip"); + const outsideBytes = Buffer.from("PK\u0003\u0004OUTSIDE-DIRECTORY-SECRET", "binary"); + await Promise.all([mkdir(workspace), mkdir(outsideDirectory)]); + await writeFile(outsideArchive, outsideBytes); + await symlink(outsideDirectory, resolve(workspace, "link")); + const instance = server(adapter({ + showRun: (id) => id === "run-directory-symlink" + ? { run: summary({ id, workspace_path: workspace, project_archive: archive }), events: [] } + : undefined + })); + + const response = await instance.inject({ method: "GET", url: "/api/runs/run-directory-symlink/archive" }); + + expect(response.statusCode).toBe(410); + expect(response.json()).toEqual({ + status: "failed", + error: { class: "archive_gone", message: "The project archive is no longer available." } + }); + expect(response.rawPayload).not.toEqual(outsideBytes); + expect(response.body).not.toContain("OUTSIDE-DIRECTORY-SECRET"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("converts unexpected request-time adapter failures to a non-sensitive 500 envelope", async () => { + const instance = server(adapter({ + listRuns: () => { + throw new Error("database /home/private/runs.sqlite token=secret stack"); + } + })); + const response = await instance.inject({ method: "GET", url: "/api/runs" }); + expect(response.statusCode).toBe(500); + expect(response.json()).toEqual({ + status: "failed", + error: { class: "internal_error", message: "The server could not complete the request." } + }); + expect(response.body).not.toMatch(/database|\/home\/|runs\.sqlite|token|secret|stack/i); + }); + + it("invokes the optional adapter close only once across repeated server.close calls", async () => { + const close = vi.fn(async () => undefined); + const instance = server(adapter({ close })); + await Promise.all([instance.close(), instance.close()]); + expect(close).toHaveBeenCalledTimes(1); + const index = servers.indexOf(instance); + if (index >= 0) servers.splice(index, 1); + }); + + it("waits for active background jobs before invoking the optional adapter close", async () => { + let finish: ((value: ServerRunResult) => void) | undefined; + const pending = new Promise((resolvePromise) => { finish = resolvePromise; }); + const close = vi.fn(async () => undefined); + const instance = server(adapter({ run: () => pending, close })); + expect((await instance.inject({ + method: "POST", + url: "/api/runs", + payload: { packagePath: "package" } + })).statusCode).toBe(202); + await waitForJob(instance, "job-fixed", "running"); + + const closing = instance.close(); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 0)); + expect(close).not.toHaveBeenCalled(); + finish?.({ run: summary() }); + await closing; + expect(close).toHaveBeenCalledTimes(1); + const index = servers.indexOf(instance); + if (index >= 0) servers.splice(index, 1); + }); +}); diff --git a/tests/server-main.test.ts b/tests/server-main.test.ts new file mode 100644 index 0000000..a221d3e --- /dev/null +++ b/tests/server-main.test.ts @@ -0,0 +1,310 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { access, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + DEFAULT_SERVER_HOST, + DEFAULT_SERVER_PORT, + readServerConfig +} from "../src/server/config.js"; + +interface CommandResult { + code: number; + stdout: string; + stderr: string; +} + +const compiledAssets = [ + "dist/schemas/coverage-report.v1.json", + "dist/schemas/evaluation-contract.v1.json", + "dist/schemas/evidence-graph.v1.json", + "dist/schemas/experiment-request.v1.json", + "dist/schemas/experiment-result.v1.json", + "dist/schemas/problem-spec.v1.json", + "dist/schemas/task-graph.v1.json", + "dist/python/modeling_agent/__init__.py", + "dist/python/modeling_agent/forecasting.py", + "dist/python/modeling_agent/io.py", + "dist/python/modeling_agent/metrics.py", + "dist/python/modeling_agent/runner.py", + "dist/python/requirements.lock", + "dist/python/standalone.Dockerfile", + "dist/python/standalone_reproduce.py" +]; + +function runCommand(command: string, args: string[], env: NodeJS.ProcessEnv = process.env): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { cwd: process.cwd(), env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { stdout += chunk; }); + child.stderr?.on("data", (chunk: string) => { stderr += chunk; }); + child.once("error", reject); + child.once("close", (code) => resolvePromise({ code: code ?? 1, stdout, stderr })); + }); +} + +interface ExitResult { + code: number | null; + signal: NodeJS.Signals | null; +} + +function waitForExit(child: ChildProcess, timeoutMs?: number): Promise { + return new Promise((resolvePromise, reject) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolvePromise({ code: child.exitCode, signal: child.signalCode }); + return; + } + let timeout: NodeJS.Timeout | undefined; + const complete = (code: number | null, signal: NodeJS.Signals | null): void => { + if (timeout) clearTimeout(timeout); + resolvePromise({ code, signal }); + }; + child.once("close", complete); + if (timeoutMs !== undefined) { + timeout = setTimeout(() => { + child.removeListener("close", complete); + reject(new Error(`Compiled server did not exit within ${timeoutMs}ms.`)); + }, timeoutMs); + } + }); +} + +async function stopServer(child: ChildProcess): Promise { + if (child.exitCode !== null || child.signalCode !== null) return { code: child.exitCode, signal: child.signalCode }; + child.kill("SIGTERM"); + try { + return await waitForExit(child, 10_000); + } catch (error) { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await waitForExit(child, 10_000).catch(() => undefined); + throw error; + } +} + +async function unusedLoopbackPort(): Promise { + const probe = createServer(); + await new Promise((resolvePromise, reject) => { + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => resolvePromise()); + }); + const address = probe.address(); + if (address === null || typeof address === "string") { + await new Promise((resolvePromise) => probe.close(() => resolvePromise())); + throw new Error("Could not determine a loopback port."); + } + const port = address.port; + await new Promise((resolvePromise, reject) => { + probe.close((error) => error ? reject(error) : resolvePromise()); + }); + return port; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)); +} + +beforeAll(async () => { + const build = await runCommand("npm", ["run", "build"]); + expect(build.code, `${build.stdout}\n${build.stderr}`).toBe(0); +}, 30_000); + +async function waitForHealth(url: string, child: ChildProcess, stderr: () => string): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error(`Compiled server exited before health check: ${stderr()}`); + } + try { + const response = await fetch(url, { signal: AbortSignal.timeout(5_000) }); + if (response.status === 200) { + await response.arrayBuffer(); + return; + } + await response.arrayBuffer(); + } catch { + // The listener may still be starting. + } + await delay(50); + } + throw new Error(`Compiled server did not become healthy: ${stderr()}`); +} + +async function waitForJob( + url: string, + child: ChildProcess, + stderr: () => string +): Promise<{ job: Record; statuses: string[] }> { + const statuses: string[] = []; + for (let attempt = 0; attempt < 720; attempt += 1) { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error(`Compiled server exited while running job: ${stderr()}`); + } + const response = await fetch(url, { signal: AbortSignal.timeout(5_000) }); + const body = await response.text(); + if (response.status !== 200) throw new Error(`Unexpected job response ${response.status}: ${body}`); + const parsed = JSON.parse(body) as { job?: Record }; + const status = parsed.job?.status; + if (typeof status !== "string") throw new Error(`Job response omitted status: ${body}`); + statuses.push(status); + if (status === "succeeded" || status === "failed") { + if (!parsed.job) throw new Error(`Job response omitted job: ${body}`); + return { job: parsed.job, statuses }; + } + await delay(250); + } + throw new Error(`Compiled job did not reach a terminal state: ${stderr()}`); +} + +describe("compiled server entry point", () => { + it("runs a real fake/local HTTP job through archive download", async () => { + const runsRoot = await mkdtemp(join(tmpdir(), "modeling-server-main-")); + const fixture = resolve(process.cwd(), "tests", "fixtures", "basic"); + const port = await unusedLoopbackPort(); + const server = spawn(process.execPath, [resolve(process.cwd(), "dist/src/server/main.js")], { + cwd: process.cwd(), + env: { + ...process.env, + MODELING_AGENT_HOST: "127.0.0.1", + MODELING_AGENT_PORT: String(port), + MODELING_AGENT_RUNS_ROOT: runsRoot + }, + stdio: ["ignore", "ignore", "pipe"] + }); + let stderr = ""; + server.stderr?.setEncoding("utf8"); + server.stderr?.on("data", (chunk: string) => { stderr += chunk; }); + + try { + const baseUrl = `http://127.0.0.1:${port}`; + await waitForHealth(`${baseUrl}/health`, server, () => stderr); + + const acceptedResponse = await fetch(`${baseUrl}/api/runs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ packagePath: fixture, runtime: "fake", execution: "local" }), + signal: AbortSignal.timeout(10_000) + }); + const acceptedBody = await acceptedResponse.text(); + expect(acceptedResponse.status).toBe(202); + expect(acceptedBody).not.toContain(fixture); + expect(acceptedBody).not.toMatch(/runs\.sqlite|stack|token/i); + const accepted = JSON.parse(acceptedBody) as { status: string; job: { id: string; status: string } }; + expect(accepted).toMatchObject({ status: "accepted", job: { status: "queued" } }); + + const jobResult = await waitForJob(`${baseUrl}/api/jobs/${accepted.job.id}`, server, () => stderr); + expect(jobResult.statuses).toContain("running"); + expect(jobResult.job).toMatchObject({ + id: accepted.job.id, + status: "succeeded", + runId: expect.stringMatching(/^run-[A-Za-z0-9._-]+$/) + }); + const runId = jobResult.job.runId as string; + expect(JSON.stringify(jobResult.job)).not.toContain(fixture); + expect(JSON.stringify(jobResult.job)).not.toMatch(/runs\.sqlite|stack|token/i); + + const shownResponse = await fetch(`${baseUrl}/api/runs/${runId}`, { signal: AbortSignal.timeout(10_000) }); + const shownBody = await shownResponse.text(); + expect(shownResponse.status).toBe(200); + expect(shownBody).not.toContain(fixture); + expect(shownBody).not.toMatch(/package_path|workspace_path|project_archive|runs\.sqlite|stack|token/i); + const shown = JSON.parse(shownBody) as { + status: string; + run: { status: string; archiveAvailable: boolean }; + events: unknown[]; + }; + expect(shown.status).toBe("ok"); + expect(["completed", "completed_with_warnings"]).toContain(shown.run.status); + expect(shown.run.archiveAvailable).toBe(true); + expect(shown.events.length).toBeGreaterThan(0); + + const archiveResponse = await fetch(`${baseUrl}/api/runs/${runId}/archive`, { signal: AbortSignal.timeout(10_000) }); + const archiveBytes = Buffer.from(await archiveResponse.arrayBuffer()); + expect(archiveResponse.status).toBe(200); + expect(archiveResponse.headers.get("content-type")).toMatch(/^application\/zip/); + expect(archiveBytes.subarray(0, 2).toString()).toBe("PK"); + const downloadedArchive = resolve(runsRoot, "downloaded-project.zip"); + await writeFile(downloadedArchive, archiveBytes); + const unzip = await runCommand("unzip", ["-t", downloadedArchive]); + expect(unzip.code, `${unzip.stdout}\n${unzip.stderr}`).toBe(0); + + const exit = await stopServer(server); + expect(exit).toEqual({ code: 0, signal: null }); + } finally { + try { + if (server.exitCode === null && server.signalCode === null) await stopServer(server); + } finally { + await rm(runsRoot, { recursive: true, force: true }); + } + } + }, 180_000); + + it("copies schemas and standalone runtime assets beside compiled modules", async () => { + await Promise.all(compiledAssets.map(async (asset) => { + await expect(access(resolve(process.cwd(), asset))).resolves.toBeUndefined(); + })); + }); +}); + +describe("compiled server startup boundaries", () => { + it("rejects a non-loopback host before listening", async () => { + const port = await unusedLoopbackPort(); + const runsRoot = await mkdtemp(join(tmpdir(), "modeling-server-rejected-")); + const child = spawn(process.execPath, [resolve(process.cwd(), "dist/src/server/main.js")], { + cwd: process.cwd(), + env: { + ...process.env, + MODELING_AGENT_HOST: "0.0.0.0", + MODELING_AGENT_PORT: String(port), + MODELING_AGENT_RUNS_ROOT: runsRoot + }, + stdio: ["ignore", "ignore", "pipe"] + }); + let stderr = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { stderr += chunk; }); + + try { + const exit = await waitForExit(child, 10_000).catch((error: unknown) => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + throw new Error(`Non-loopback server did not exit: ${stderr}`, { cause: error }); + }); + expect(exit).toEqual({ code: 1, signal: null }); + expect(stderr).toMatch(/Refusing unauthenticated non-loopback host/i); + } finally { + try { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await waitForExit(child, 10_000).catch(() => undefined); + } + } finally { + await rm(runsRoot, { recursive: true, force: true }); + } + } + }, 30_000); +}); + +describe("server configuration", () => { + it("uses loopback-only defaults", () => { + expect(readServerConfig({})).toEqual({ + host: DEFAULT_SERVER_HOST, + port: DEFAULT_SERVER_PORT, + runsRoot: "runs" + }); + }); + + it("reads explicit host, port, and runs root", () => { + expect(readServerConfig({ + MODELING_AGENT_HOST: "::1", + MODELING_AGENT_PORT: "5432", + MODELING_AGENT_RUNS_ROOT: "./temporary-runs" + })).toEqual({ host: "::1", port: 5432, runsRoot: "./temporary-runs" }); + }); + + it.each(["", "0", "65536", "abc", "43.17"])("rejects invalid port %s", (port) => { + expect(() => readServerConfig({ MODELING_AGENT_PORT: port })).toThrow(/port/i); + }); +});