From 0bd6b2d282fa1b5e7998fa7e1f7c77bb10f11638 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Fri, 31 Jul 2026 07:12:32 +0200 Subject: [PATCH 1/2] refactor(dev-platform): one-way layering + namespaced config (C3) --- middleware/src/auth/publicPaths.ts | 2 +- middleware/src/config.ts | 146 +++++++++- middleware/src/devplatform/config.ts | 141 ++++++++++ .../{ => devplatform}/routes/devPlatform.ts | 8 +- .../routes/devPlatformGates.ts | 2 +- .../routes/devPlatformGithubApp.ts | 6 +- .../routes/devPlatformRepos.ts | 2 +- .../routes/devPlatformShared.ts | 22 +- .../{ => devplatform}/routes/devRunnerApi.ts | 10 +- .../routes/devRunnerJobPolicyRoute.ts | 4 +- .../{ => devplatform}/routes/devWebhooks.ts | 6 +- .../src/devplatform/triggers/trackerPoller.ts | 2 +- .../devplatform/triggers/trackerRegistry.ts | 2 +- middleware/src/devplatform/wireDevPlatform.ts | 255 ++++++++++-------- middleware/src/index.ts | 130 ++------- .../src/routes/conductorWebhooksInbound.ts | 2 +- .../devplatform/chatDevJobService.pg.test.ts | 2 +- .../devplatform/chatDevJobToolWiring.test.ts | 2 +- .../test/devplatform/deriveJobPolicy.test.ts | 2 +- .../devJobOrchestratorTool.test.ts | 2 +- .../test/devplatform/devPlatform.e2e.test.ts | 55 ++-- .../devPlatformBootRefusal.test.ts | 114 ++++++++ .../devplatform/devPlatformConfig.harness.ts | 40 +++ .../devPlatformDeviceFlowWiring.test.ts | 2 +- .../test/devplatform/devPlatformGates.test.ts | 2 +- .../devplatform/devPlatformGithubApp.test.ts | 2 +- .../devPlatformPipeline.wire.pg.test.ts | 14 +- .../devplatform/devPlatformRoutes.harness.ts | 2 +- .../devplatform/devPlatformRoutes.test.ts | 2 +- .../test/devplatform/devRunnerApi.harness.ts | 2 +- .../test/devplatform/devWebhooks.test.ts | 2 +- .../devWebhooksConcurrency.pg.test.ts | 2 +- .../devplatform/goldenFixture.e2e.test.ts | 17 +- middleware/test/devplatform/llmProxy.test.ts | 2 +- .../devplatform/llmProxyAccounting.pg.test.ts | 2 +- .../devplatform/scopedScmToken.pg.test.ts | 14 +- .../test/devplatform/trackerPoller.pg.test.ts | 2 +- .../test/devplatform/trackerRegistry.test.ts | 2 +- 38 files changed, 687 insertions(+), 339 deletions(-) create mode 100644 middleware/src/devplatform/config.ts rename middleware/src/{ => devplatform}/routes/devPlatform.ts (98%) rename middleware/src/{ => devplatform}/routes/devPlatformGates.ts (99%) rename middleware/src/{ => devplatform}/routes/devPlatformGithubApp.ts (98%) rename middleware/src/{ => devplatform}/routes/devPlatformRepos.ts (99%) rename middleware/src/{ => devplatform}/routes/devPlatformShared.ts (96%) rename middleware/src/{ => devplatform}/routes/devRunnerApi.ts (99%) rename middleware/src/{ => devplatform}/routes/devRunnerJobPolicyRoute.ts (98%) rename middleware/src/{ => devplatform}/routes/devWebhooks.ts (98%) create mode 100644 middleware/test/devplatform/devPlatformBootRefusal.test.ts create mode 100644 middleware/test/devplatform/devPlatformConfig.harness.ts diff --git a/middleware/src/auth/publicPaths.ts b/middleware/src/auth/publicPaths.ts index 903c9a52..73689c14 100644 --- a/middleware/src/auth/publicPaths.ts +++ b/middleware/src/auth/publicPaths.ts @@ -28,7 +28,7 @@ export const STATIC_PUBLIC_PATHS: readonly RegExp[] = [ // Epic #470 — the dev-platform runner phone-home router. A runner is a // process, not an operator: it holds a one-time job token and no session // cookie. Every request is authenticated against the job-token hash in - // routes/devRunnerApi.ts — that IS its authentication. + // devplatform/routes/devRunnerApi.ts — that IS its authentication. /^\/api\/v1\/dev-runner(?:\/|$|\?)/, // Epic #470 — GitHub redirects finish the dev-platform GitHub-App setup on a // signed state token / installation ownership check, not on an operator session. diff --git a/middleware/src/config.ts b/middleware/src/config.ts index de63ee5c..44a9e8e5 100644 --- a/middleware/src/config.ts +++ b/middleware/src/config.ts @@ -5,6 +5,10 @@ import dotenv from 'dotenv'; import { z } from 'zod'; import type { RegistryConfigEntry } from './api/registry-v1.js'; +// TYPE-ONLY (erased at build): core builds the dev platform's config namespace but +// does not otherwise depend on the subsystem. When the dev platform is extracted, +// this import and `buildDevPlatformConfig` below are the only things to delete. +import type { DevPlatformConfig } from './devplatform/config.js'; // Resolve .env relative to this file so the server works from any CWD. const here = path.dirname(fileURLToPath(import.meta.url)); @@ -599,7 +603,132 @@ const ConfigSchema = z.object({ DEV_FLY_REGION: optionalNonEmpty(z.string().min(1)), }); -export type Config = z.infer; +/** + * Schema keys that begin `DEV_`/`FLY_` but are core, NOT dev-platform. Everything + * else with those prefixes is collapsed out of the top-level `Config` and served + * only through `config.devPlatform` (epic #470 C3). + * + * - `DEV_ENDPOINTS_ENABLED` — the core dev-graph endpoints (`/api/dev/*`). + * - `FLY_APP_NAME` — Fly-injected identity of THIS app. The dev platform + * reads its value (copied into `devPlatform.fly`), + * but the key describes the host, not the feature, so + * it must survive the extraction. + * + * A third lookalike needs no entry here because it does not carry either prefix: + * `PLUGIN_DEV_DIR`, the plugin author's local-dev source directory. + * + * Stating the EXCEPTIONS rather than enumerating the 40 members is deliberate: a + * new `DEV_PLATFORM_*` key added to the schema lands in the namespace on its own, + * with no second list to forget. A new *core* key with those prefixes must be + * added here — and that is the change that deserves the review attention. + */ +const CORE_DEV_PREFIXED_KEYS = ['DEV_ENDPOINTS_ENABLED', 'FLY_APP_NAME'] as const; +type CoreDevPrefixedKey = (typeof CORE_DEV_PREFIXED_KEYS)[number]; + +type ParsedConfig = z.infer; + +/** Every schema key the dev platform owns: prefix-matched, minus the exceptions. */ +type DevPlatformEnvKey = Exclude< + Extract, + CoreDevPrefixedKey +>; + +/** Runtime half of `DevPlatformEnvKey` — same rule, applied to actual key strings. */ +function isDevPlatformEnvKey(key: string): boolean { + if ((CORE_DEV_PREFIXED_KEYS as readonly string[]).includes(key)) return false; + return key.startsWith('DEV_') || key.startsWith('FLY_'); +} + +/** + * The core config: everything the schema parses EXCEPT the dev-platform keys, + * which are reachable only via `config.devPlatform`. Collapsing them out of the + * top level is what makes the later extraction mechanical — when the subsystem + * leaves, one property and one builder disappear instead of 40 call sites. + */ +export type Config = Omit & { + devPlatform: DevPlatformConfig; +}; + +/** Comma-separated env list → trimmed non-empty entries (egress allowlist, model + * allowlist). Entry-level validation happens in the consumer (deriveJobPolicy). */ +function csvList(raw: string): string[] { + return raw + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +/** + * Build the dev-platform namespace from the parsed env. The ONLY place that maps + * `DEV_*`/`FLY_*` env names onto the subsystem's own vocabulary — everything + * downstream takes the object. Post-processing that used to be scattered lives + * here: the runner base-URL default from `PORT`, `resolvePath` on the workspace + * dir, the `DEV_RUNNER_IMAGE ?? DEV_RUNNER_DEFAULT_IMAGE` fallback, the + * `DEV_PLATFORM_GITHUB_CLIENT_ID ?? GITHUB_OAUTH_CLIENT_ID` fallback, and the two + * comma-separated list splits. + */ +function buildDevPlatformConfig(parsed: ParsedConfig): DevPlatformConfig { + return { + enabled: parsed.DEV_PLATFORM_ENABLED, + // Runner phone-home base URL: explicit override, else loopback + PORT. + baseUrl: + parsed.DEV_PLATFORM_RUNNER_BASE_URL ?? `http://127.0.0.1:${String(parsed.PORT)}`, + cliBin: parsed.DEV_PLATFORM_CLI_BIN, + wallClockMs: parsed.DEV_PLATFORM_JOB_WALL_CLOCK_MS, + heartbeatTimeoutMs: parsed.DEV_PLATFORM_HEARTBEAT_TIMEOUT_MS, + maxConcurrentJobs: parsed.DEV_PLATFORM_MAX_CONCURRENT_JOBS, + commitAuthor: parsed.DEV_PLATFORM_COMMIT_AUTHOR, + subscriptionModeEnabled: parsed.DEV_PLATFORM_SUBSCRIPTION_MODE, + subscriptionAck: parsed.DEV_PLATFORM_SUBSCRIPTION_ACK, + workspaceDir: resolvePath(parsed.DEV_PLATFORM_WORKSPACE_DIR), + unsafeLocal: parsed.DEV_PLATFORM_UNSAFE_LOCAL, + localUid: parsed.DEV_PLATFORM_LOCAL_UID, + githubClientId: parsed.DEV_PLATFORM_GITHUB_CLIENT_ID ?? parsed.GITHUB_OAUTH_CLIENT_ID, + daemonToken: parsed.DEV_RUNNER_DAEMON_TOKEN, + daemonUrl: parsed.DEV_RUNNER_DAEMON_URL, + backend: parsed.DEV_PLATFORM_BACKEND, + leaseTtlSec: parsed.DEV_JOB_LEASE_TTL_SEC, + // `DEV_RUNNER_IMAGE` wins when set (the daemon's own allowlist config uses + // that name too, so one operator-set var keeps every side in agreement); + // `DEV_RUNNER_DEFAULT_IMAGE` is the fallback. + runnerImage: parsed.DEV_RUNNER_IMAGE ?? parsed.DEV_RUNNER_DEFAULT_IMAGE, + egressBaseAllowlist: parsed.DEV_EGRESS_BASE_ALLOWLIST + ? csvList(parsed.DEV_EGRESS_BASE_ALLOWLIST) + : undefined, + middlewareHost: parsed.DEV_PLATFORM_MIDDLEWARE_HOST, + llm: { + provider: parsed.DEV_PLATFORM_LLM_PROVIDER, + upstreamBaseUrl: parsed.DEV_PLATFORM_LLM_UPSTREAM_BASE_URL, + allowedModels: parsed.DEV_PLATFORM_LLM_ALLOWED_MODELS + ? csvList(parsed.DEV_PLATFORM_LLM_ALLOWED_MODELS) + : [], + defaultBudgetCostUsd: parsed.DEV_JOB_DEFAULT_BUDGET_USD, + maxOutputTokens: parsed.DEV_JOB_MAX_OUTPUT_TOKENS, + }, + fly: { + runnerApp: parsed.DEV_FLY_RUNNER_APP, + hostAppName: parsed.FLY_APP_NAME, + phoneHomeUrl: parsed.DEV_FLY_PHONE_HOME_URL, + publicBaseUrl: parsed.PUBLIC_BASE_URL, + maxCpus: parsed.DEV_FLY_MAX_CPUS, + maxMemoryMb: parsed.DEV_FLY_MAX_MEMORY_MB, + guestCpus: parsed.DEV_FLY_GUEST_CPUS, + guestMemoryMb: parsed.DEV_FLY_GUEST_MEMORY_MB, + region: parsed.DEV_FLY_REGION, + }, + webhooks: { + enabled: parsed.DEV_WEBHOOKS_ENABLED, + maxJobsPerRepoHour: parsed.DEV_WEBHOOK_MAX_JOBS_PER_REPO_HOUR, + maxJobsPerSenderHour: parsed.DEV_WEBHOOK_MAX_JOBS_PER_SENDER_HOUR, + }, + retention: { + eventRetentionDays: parsed.DEV_PLATFORM_EVENT_RETENTION_DAYS, + auditRetentionDays: parsed.DEV_PLATFORM_AUDIT_RETENTION_DAYS, + maxEventsPerJob: parsed.DEV_JOB_MAX_EVENTS, + artifactMaxBytes: parsed.DEV_ARTIFACT_MAX_BYTES, + }, + }; +} // Relative path-like settings are resolved against the middleware root so the server // works regardless of the CWD (local dev, Docker, Fly machine, tests). @@ -682,8 +811,16 @@ function loadConfig(): Config { if (refusals.length > 0) { throw new Error(`Invalid configuration:\n${refusals.map((r) => ` - ${r}`).join('\n')}`); } + // The dev-platform keys are lifted out of the top level and served only through + // `devPlatform`. Deleting them from the returned object (rather than merely + // hiding them in the type) means there is no second, stale way to read them. + const core: Record = { ...parsed.data }; + for (const key of Object.keys(core)) { + if (isDevPlatformEnvKey(key)) delete core[key]; + } + return { - ...parsed.data, + ...(core as Omit), MEMORY_SEED_DIR: resolvePath(parsed.data.MEMORY_SEED_DIR), SKILLS_DIR: resolvePath(parsed.data.SKILLS_DIR), UPLOADED_PACKAGES_DIR: resolveStateDir( @@ -694,10 +831,7 @@ function loadConfig(): Config { PLUGIN_DEV_DIR: parsed.data.PLUGIN_DEV_DIR ? resolvePath(parsed.data.PLUGIN_DEV_DIR) : undefined, - // Runner phone-home base URL: explicit override, else loopback + PORT. - DEV_PLATFORM_RUNNER_BASE_URL: - parsed.data.DEV_PLATFORM_RUNNER_BASE_URL ?? `http://127.0.0.1:${String(parsed.data.PORT)}`, - DEV_PLATFORM_WORKSPACE_DIR: resolvePath(parsed.data.DEV_PLATFORM_WORKSPACE_DIR), + devPlatform: buildDevPlatformConfig(parsed.data), }; } diff --git a/middleware/src/devplatform/config.ts b/middleware/src/devplatform/config.ts new file mode 100644 index 00000000..e62f8951 --- /dev/null +++ b/middleware/src/devplatform/config.ts @@ -0,0 +1,141 @@ +/** + * Epic #470 — the dev-platform's own configuration namespace. + * + * WHY THIS FILE EXISTS + * -------------------- + * Core's `src/config.ts` owns the zod schema that reads `process.env` — that does + * not change, and it stays in core when the dev platform is extracted. What used + * to be spread across `index.ts` was the *shape*: ~20 individual `config.DEV_*` + * values threaded one by one into `assembleDevPlatform`, plus a handful of + * derivations (comma-separated list splitting, the runner-image fallback, the + * GitHub client-id fallback) done inline at the call site. + * + * This interface is the single argument the assembly takes instead. Core builds + * one of these in `config.ts` (`config.devPlatform`) and hands it over; nothing + * outside the boundary needs to know an env-var name again. When the subsystem + * moves to a plugin, the plugin brings this contract with it and core deletes + * exactly one builder function. + * + * OPTIONALITY IS DELIBERATE and mirrors what `assembleDevPlatform` already did + * with its individual fields: an optional field keeps the assembly's own `??` + * fallback, so a test that omits it behaves exactly as it did before this shape + * existed. Fields the real boot always supplies (because zod gives them a + * default) are required. + */ + +/** Guest sizing for a Fly machine, before the ceilings are applied. */ +export interface DevPlatformFlyConfig { + /** `DEV_FLY_RUNNER_APP` — the DEDICATED runner app. Absent ⇒ the Fly backend is + * not registered at all. NEVER the middleware's own app (refused at assembly). */ + runnerApp?: string | undefined; + /** `FLY_APP_NAME` — Fly-injected, core-owned. Copied in here because its + * PRESENCE is the on-/off-Fly detector (internal Machines API + `.internal` + * phone-home) and its VALUE is what the dedicated-app refusal compares against. */ + hostAppName?: string | undefined; + /** `DEV_FLY_PHONE_HOME_URL` — operator override for the shim phone-home URL. */ + phoneHomeUrl?: string | undefined; + /** `PUBLIC_BASE_URL` — core-owned; the off-Fly phone-home fallback. */ + publicBaseUrl?: string | undefined; + /** `DEV_FLY_MAX_CPUS` ceiling (a per-job request above it is clamped). */ + maxCpus?: number | undefined; + /** `DEV_FLY_MAX_MEMORY_MB` ceiling. */ + maxMemoryMb?: number | undefined; + /** `DEV_FLY_GUEST_CPUS` — default guest size. */ + guestCpus?: number | undefined; + /** `DEV_FLY_GUEST_MEMORY_MB` — default guest size. */ + guestMemoryMb?: number | undefined; + /** `DEV_FLY_REGION` — optional placement (Fly picks one when unset). */ + region?: string | undefined; +} + +/** LLM-proxy policy (spec §6b). */ +export interface DevPlatformLlmConfig { + /** `DEV_PLATFORM_LLM_PROVIDER` — vault provider segment. */ + provider?: string | undefined; + /** `DEV_PLATFORM_LLM_UPSTREAM_BASE_URL`. */ + upstreamBaseUrl?: string | undefined; + /** `DEV_PLATFORM_LLM_ALLOWED_MODELS`, already split. Empty ⇒ the proxy 500s. */ + allowedModels?: readonly string[] | undefined; + /** `DEV_JOB_DEFAULT_BUDGET_USD`. */ + defaultBudgetCostUsd?: number | undefined; + /** `DEV_JOB_MAX_OUTPUT_TOKENS` — the `max_tokens` clamp ceiling. */ + maxOutputTokens?: number | undefined; +} + +/** GitHub webhook trigger controls (spec §3). Read by the mount site, not the assembly. */ +export interface DevPlatformWebhooksConfig { + /** `DEV_WEBHOOKS_ENABLED` — global kill switch. */ + enabled: boolean; + /** `DEV_WEBHOOK_MAX_JOBS_PER_REPO_HOUR`. */ + maxJobsPerRepoHour: number; + /** `DEV_WEBHOOK_MAX_JOBS_PER_SENDER_HOUR`. */ + maxJobsPerSenderHour: number; +} + +/** Data-lifecycle bounds (spec §7). Read by the retention cron + the job store. */ +export interface DevPlatformRetentionConfig { + /** `DEV_PLATFORM_EVENT_RETENTION_DAYS` — low-value telemetry prune age. */ + eventRetentionDays: number; + /** `DEV_PLATFORM_AUDIT_RETENTION_DAYS` — audit-grade outer bound. */ + auditRetentionDays: number; + /** `DEV_JOB_MAX_EVENTS` — per-job event cap. */ + maxEventsPerJob: number; + /** `DEV_ARTIFACT_MAX_BYTES` — inline artifact ceiling. */ + artifactMaxBytes: number; +} + +/** + * Everything the dev platform is configured by, in one namespace. + * Every field names the env var it comes from; core's `config.ts` is the only + * place that reads those names. + */ +export interface DevPlatformConfig { + /** `DEV_PLATFORM_ENABLED` — dark by default; false mounts nothing. */ + enabled: boolean; + /** `DEV_PLATFORM_RUNNER_BASE_URL`, already defaulted to loopback + `PORT`. */ + baseUrl: string; + /** `DEV_PLATFORM_CLI_BIN`. */ + cliBin: string; + /** `DEV_PLATFORM_JOB_WALL_CLOCK_MS`. */ + wallClockMs: number; + /** `DEV_PLATFORM_HEARTBEAT_TIMEOUT_MS`. */ + heartbeatTimeoutMs: number; + /** `DEV_PLATFORM_MAX_CONCURRENT_JOBS`. */ + maxConcurrentJobs: number; + /** `DEV_PLATFORM_COMMIT_AUTHOR` — `Name `. */ + commitAuthor: string; + /** `DEV_PLATFORM_SUBSCRIPTION_MODE`. */ + subscriptionModeEnabled: boolean; + /** `DEV_PLATFORM_SUBSCRIPTION_ACK` — the paired acknowledgment the boot refusal + * demands. Carried here so the whole namespace is one object; only the refusal + * reads it. */ + subscriptionAck?: string | undefined; + /** `DEV_PLATFORM_WORKSPACE_DIR`, already run through `resolvePath`. */ + workspaceDir: string; + /** `DEV_PLATFORM_UNSAFE_LOCAL`. */ + unsafeLocal: boolean; + /** `DEV_PLATFORM_LOCAL_UID` — required by the refusal whenever `unsafeLocal`. */ + localUid?: number | undefined; + /** `DEV_PLATFORM_GITHUB_CLIENT_ID`, falling back to `GITHUB_OAUTH_CLIENT_ID`. */ + githubClientId?: string | undefined; + /** `DEV_RUNNER_DAEMON_TOKEN`. Absent ⇒ job-policy endpoint 503s, no DockerBackend. */ + daemonToken?: string | undefined; + /** `DEV_RUNNER_DAEMON_URL`. Absent ⇒ no DockerBackend. */ + daemonUrl?: string | undefined; + /** `DEV_PLATFORM_BACKEND` — which runner backend ships. Always present (the + * schema defaults it to `docker`), so consumers never re-default it. */ + backend: 'docker' | 'local'; + /** `DEV_JOB_LEASE_TTL_SEC`. */ + leaseTtlSec?: number | undefined; + /** `DEV_RUNNER_IMAGE`, falling back to `DEV_RUNNER_DEFAULT_IMAGE`. Absent ⇒ the + * job-policy endpoint 503s and the Fly backend is not registered. */ + runnerImage?: string | undefined; + /** `DEV_EGRESS_BASE_ALLOWLIST`, already split on commas. */ + egressBaseAllowlist?: readonly string[] | undefined; + /** `DEV_PLATFORM_MIDDLEWARE_HOST`. Absent ⇒ derived from `baseUrl`. */ + middlewareHost?: string | undefined; + llm?: DevPlatformLlmConfig | undefined; + fly?: DevPlatformFlyConfig | undefined; + webhooks: DevPlatformWebhooksConfig; + retention: DevPlatformRetentionConfig; +} diff --git a/middleware/src/routes/devPlatform.ts b/middleware/src/devplatform/routes/devPlatform.ts similarity index 98% rename from middleware/src/routes/devPlatform.ts rename to middleware/src/devplatform/routes/devPlatform.ts index c410eea6..8861f4fe 100644 --- a/middleware/src/routes/devPlatform.ts +++ b/middleware/src/devplatform/routes/devPlatform.ts @@ -19,9 +19,9 @@ import { Router, json as expressJson } from 'express'; import type { NextFunction, Request, Response } from 'express'; -import { composeBrief } from '../devplatform/briefComposer.js'; -import { mintRunnerToken } from '../devplatform/jobToken.js'; -import type { ListJobsFilter } from '../devplatform/devJobStore.js'; +import { composeBrief } from '../briefComposer.js'; +import { mintRunnerToken } from '../jobToken.js'; +import type { ListJobsFilter } from '../devJobStore.js'; import { isDevJobAuthMode, isDevJobKind, @@ -32,7 +32,7 @@ import { type DevJobEvent, type DevJobKind, type DevJobSource, -} from '../devplatform/types.js'; +} from '../types.js'; import { registerDevPlatformRepoRoutes } from './devPlatformRepos.js'; import { DevPlatformError, diff --git a/middleware/src/routes/devPlatformGates.ts b/middleware/src/devplatform/routes/devPlatformGates.ts similarity index 99% rename from middleware/src/routes/devPlatformGates.ts rename to middleware/src/devplatform/routes/devPlatformGates.ts index 76a0cfc6..7d5edb70 100644 --- a/middleware/src/routes/devPlatformGates.ts +++ b/middleware/src/devplatform/routes/devPlatformGates.ts @@ -1,7 +1,7 @@ import { Router, json as expressJson } from 'express'; import type { Request, Response } from 'express'; -import type { DevJobGate, DevJobGateStore, GateAnswer } from '../devplatform/pipeline/gateStore.js'; +import type { DevJobGate, DevJobGateStore, GateAnswer } from '../pipeline/gateStore.js'; /** * Epic #470 W2 — the human-gate admin routes (spec §5). diff --git a/middleware/src/routes/devPlatformGithubApp.ts b/middleware/src/devplatform/routes/devPlatformGithubApp.ts similarity index 98% rename from middleware/src/routes/devPlatformGithubApp.ts rename to middleware/src/devplatform/routes/devPlatformGithubApp.ts index b69c4e98..e5b79660 100644 --- a/middleware/src/routes/devPlatformGithubApp.ts +++ b/middleware/src/devplatform/routes/devPlatformGithubApp.ts @@ -1,7 +1,7 @@ import { Router, json as expressJson } from 'express'; import type { Request, Response } from 'express'; -import { mintAppJwt } from '../services/githubAppJwt.js'; +import { mintAppJwt } from '../../services/githubAppJwt.js'; import { buildManifest, exchangeManifestCode, @@ -9,12 +9,12 @@ import { type AppConversion, type ConversionFetch, type ManifestFlowStore, -} from '../devplatform/githubApp/manifestFlow.js'; +} from '../githubApp/manifestFlow.js'; import type { DevGithubApp, DevGithubAppInstallation, DevGithubAppSecrets, -} from '../devplatform/githubApp/appStore.js'; +} from '../githubApp/appStore.js'; import { DevPlatformError, handler, diff --git a/middleware/src/routes/devPlatformRepos.ts b/middleware/src/devplatform/routes/devPlatformRepos.ts similarity index 99% rename from middleware/src/routes/devPlatformRepos.ts rename to middleware/src/devplatform/routes/devPlatformRepos.ts index 9e0ea78c..2b80bace 100644 --- a/middleware/src/routes/devPlatformRepos.ts +++ b/middleware/src/devplatform/routes/devPlatformRepos.ts @@ -17,7 +17,7 @@ import type { Router, Request } from 'express'; -import type { DevRepo, DevRepoCredentialKind, NewDevRepo } from '../devplatform/types.js'; +import type { DevRepo, DevRepoCredentialKind, NewDevRepo } from '../types.js'; import { DevPlatformError, defaultCheckBranchProtection, diff --git a/middleware/src/routes/devPlatformShared.ts b/middleware/src/devplatform/routes/devPlatformShared.ts similarity index 96% rename from middleware/src/routes/devPlatformShared.ts rename to middleware/src/devplatform/routes/devPlatformShared.ts index ea50b55f..44d46b51 100644 --- a/middleware/src/routes/devPlatformShared.ts +++ b/middleware/src/devplatform/routes/devPlatformShared.ts @@ -10,16 +10,16 @@ import type { Request, Response } from 'express'; -import { checkBranchProtection as realCheckBranchProtection } from '../devplatform/branchProtectionCheck.js'; -import type { BranchProtectionResult } from '../devplatform/branchProtectionCheck.js'; -import type { DevJobEventBus } from '../devplatform/devJobEventBus.js'; -import type { DevRepoConnection } from '../devplatform/devRepoCredentials.js'; -import type { ListJobsFilter } from '../devplatform/devJobStore.js'; -import type { FinalizeContext } from '../devplatform/finalizeDevJob.js'; -import type { ApplyJobOutcome } from '../devplatform/devJobWorker.js'; -import type { GitHubDeviceFlowProvider } from '../issues/githubOAuthProvider.js'; -import type { DeviceFlowStore } from '../issues/deviceFlowStore.js'; -import type { Ticket } from '../devplatform/githubIssuesTracker.js'; +import { checkBranchProtection as realCheckBranchProtection } from '../branchProtectionCheck.js'; +import type { BranchProtectionResult } from '../branchProtectionCheck.js'; +import type { DevJobEventBus } from '../devJobEventBus.js'; +import type { DevRepoConnection } from '../devRepoCredentials.js'; +import type { ListJobsFilter } from '../devJobStore.js'; +import type { FinalizeContext } from '../finalizeDevJob.js'; +import type { ApplyJobOutcome } from '../devJobWorker.js'; +import type { GitHubDeviceFlowProvider } from '../../issues/githubOAuthProvider.js'; +import type { DeviceFlowStore } from '../../issues/deviceFlowStore.js'; +import type { Ticket } from '../githubIssuesTracker.js'; import { isTerminalDevJobStatus, type DevJob, @@ -34,7 +34,7 @@ import { type NewDevJob, type NewDevRepo, type RunnerBackendKind, -} from '../devplatform/types.js'; +} from '../types.js'; // --------------------------------------------------------------------------- // Injected store / service seams. diff --git a/middleware/src/routes/devRunnerApi.ts b/middleware/src/devplatform/routes/devRunnerApi.ts similarity index 99% rename from middleware/src/routes/devRunnerApi.ts rename to middleware/src/devplatform/routes/devRunnerApi.ts index 990190d7..cc1695d6 100644 --- a/middleware/src/routes/devRunnerApi.ts +++ b/middleware/src/devplatform/routes/devRunnerApi.ts @@ -24,15 +24,15 @@ import { Router, json as expressJson, text as expressText } from 'express'; import type { NextFunction, Request, Response, Router as ExpressRouter } from 'express'; -import type { DeriveJobPolicyConfig } from '../devplatform/deriveJobPolicy.js'; +import type { DeriveJobPolicyConfig } from '../deriveJobPolicy.js'; import { mountJobPolicyRoute } from './devRunnerJobPolicyRoute.js'; -import type { FinalizeContext } from '../devplatform/finalizeDevJob.js'; -import type { RunnerEventInput } from '../devplatform/devJobStore.js'; +import type { FinalizeContext } from '../finalizeDevJob.js'; +import type { RunnerEventInput } from '../devJobStore.js'; import { StalePhaseError, type PhaseDirective, type PhaseResultInput, -} from '../devplatform/pipeline/phaseEngine.js'; +} from '../pipeline/phaseEngine.js'; import { RUNNER_PROTOCOL_VERSION, isDevJobEventType, @@ -47,7 +47,7 @@ import { type DevReviewFinding, type DevJobUsage, type DevRepo, -} from '../devplatform/types.js'; +} from '../types.js'; // --------------------------------------------------------------------------- // Injected seams. Each is the narrow structural slice this router needs, so a diff --git a/middleware/src/routes/devRunnerJobPolicyRoute.ts b/middleware/src/devplatform/routes/devRunnerJobPolicyRoute.ts similarity index 98% rename from middleware/src/routes/devRunnerJobPolicyRoute.ts rename to middleware/src/devplatform/routes/devRunnerJobPolicyRoute.ts index 18c29f6e..4915c0ac 100644 --- a/middleware/src/routes/devRunnerJobPolicyRoute.ts +++ b/middleware/src/devplatform/routes/devRunnerJobPolicyRoute.ts @@ -29,8 +29,8 @@ import { deriveJobPolicy, JobPolicyError, type DeriveJobPolicyConfig, -} from '../devplatform/deriveJobPolicy.js'; -import type { DevJob, DevRepo } from '../devplatform/types.js'; +} from '../deriveJobPolicy.js'; +import type { DevJob, DevRepo } from '../types.js'; /** The narrow slices this route needs from the runner router's deps. */ export interface JobPolicyRouteDeps { diff --git a/middleware/src/routes/devWebhooks.ts b/middleware/src/devplatform/routes/devWebhooks.ts similarity index 98% rename from middleware/src/routes/devWebhooks.ts rename to middleware/src/devplatform/routes/devWebhooks.ts index e7dc4427..e988ad88 100644 --- a/middleware/src/routes/devWebhooks.ts +++ b/middleware/src/devplatform/routes/devWebhooks.ts @@ -28,9 +28,9 @@ import crypto from 'node:crypto'; import express, { Router } from 'express'; import type { Request, Response } from 'express'; -import type { DevRepo, RunnerBackendKind } from '../devplatform/types.js'; -import type { CreateTriggerJobInput, CreateTriggerJobResult } from '../devplatform/triggers/triggerJobService.js'; -import type { WebhookDeliveryOutcome } from '../devplatform/triggers/webhookDeliveryStore.js'; +import type { DevRepo, RunnerBackendKind } from '../types.js'; +import type { CreateTriggerJobInput, CreateTriggerJobResult } from '../triggers/triggerJobService.js'; +import type { WebhookDeliveryOutcome } from '../triggers/webhookDeliveryStore.js'; const RAW_BODY_LIMIT = '512kb'; const ONE_HOUR_MS = 3_600_000; diff --git a/middleware/src/devplatform/triggers/trackerPoller.ts b/middleware/src/devplatform/triggers/trackerPoller.ts index 66046784..fcbc5882 100644 --- a/middleware/src/devplatform/triggers/trackerPoller.ts +++ b/middleware/src/devplatform/triggers/trackerPoller.ts @@ -32,7 +32,7 @@ import type { Pool } from 'pg'; -import type { DevPlatformTracker } from '../../routes/devPlatformShared.js'; +import type { DevPlatformTracker } from '../routes/devPlatformShared.js'; import type { Ticket } from '../githubIssuesTracker.js'; import type { CreateTriggerJobInput, diff --git a/middleware/src/devplatform/triggers/trackerRegistry.ts b/middleware/src/devplatform/triggers/trackerRegistry.ts index 8a3cd623..deaf08cd 100644 --- a/middleware/src/devplatform/triggers/trackerRegistry.ts +++ b/middleware/src/devplatform/triggers/trackerRegistry.ts @@ -19,7 +19,7 @@ */ import { GithubIssuesTracker, type IssuesFetch } from '../githubIssuesTracker.js'; -import type { DevPlatformTracker } from '../../routes/devPlatformShared.js'; +import type { DevPlatformTracker } from '../routes/devPlatformShared.js'; import type { DevRepo } from '../types.js'; /** A plugin's tracker factory: given a bound repo, hand back a `DevPlatformTracker`. diff --git a/middleware/src/devplatform/wireDevPlatform.ts b/middleware/src/devplatform/wireDevPlatform.ts index 95f50079..6c94f2a3 100644 --- a/middleware/src/devplatform/wireDevPlatform.ts +++ b/middleware/src/devplatform/wireDevPlatform.ts @@ -32,9 +32,10 @@ import { DevGithubAppStore } from './githubApp/appStore.js'; import { JobTokenRegistry, mintScopedInstallationToken, revokeInstallationToken, type TokenFetch } from './githubApp/installationTokens.js'; import { GithubForgeClient, type ForgeFetch } from './githubForgeClient.js'; import { GithubIssuesTracker } from './githubIssuesTracker.js'; +import type { DevPlatformConfig } from './config.js'; import { DevJobGateStore, type DevJobGate, type GateAnswer } from './pipeline/gateStore.js'; import { PhaseEngine } from './pipeline/phaseEngine.js'; -import { createDevPlatformGatesRouter } from '../routes/devPlatformGates.js'; +import { createDevPlatformGatesRouter } from './routes/devPlatformGates.js'; import { LocalProcessBackend } from './localProcessBackend.js'; import { DockerBackend } from './dockerBackend.js'; import { FlyMachinesBackend, type FlyGuest } from './flyMachinesBackend.js'; @@ -42,13 +43,13 @@ import type { ForgeClient } from './forgeClient.js'; import type { DevJob, DevJobStatus, RunnerBackend } from './types.js'; import { isTerminalDevJobStatus } from './types.js'; import type { SecretVault } from '../secrets/vault.js'; -import { createDevPlatformRouter } from '../routes/devPlatform.js'; +import { createDevPlatformRouter } from './routes/devPlatform.js'; import type { DevPlatformDeviceFlow, DevPlatformTracker, RepoAccessResult, -} from '../routes/devPlatformShared.js'; -import { createDevRunnerRouter } from '../routes/devRunnerApi.js'; +} from './routes/devPlatformShared.js'; +import { createDevRunnerRouter } from './routes/devRunnerApi.js'; import { createLlmProxyRouter, type LlmModelPolicy } from './llmProxy.js'; import { createLlmProxyAccounting } from './llmProxyAccounting.js'; import { priceForModel } from '@omadia/usage-telemetry'; @@ -74,91 +75,32 @@ const DEFAULT_JOB_BUDGET_USD = 5; export interface WireDevPlatformDeps { pool: Pool; vault: SecretVault; - /** Where the runner phones home (`DEV_PLATFORM_RUNNER_BASE_URL`). */ - baseUrl: string; - cliBin: string; - wallClockMs: number; - heartbeatTimeoutMs: number; - maxConcurrentJobs: number; - /** `DEV_PLATFORM_COMMIT_AUTHOR` — `Name `. */ - commitAuthor: string; - subscriptionModeEnabled: boolean; - workspaceDir: string; - unsafeLocal: boolean; - localUid?: number | undefined; + /** + * The whole dev-platform configuration namespace, built once in core's + * `config.ts` (`config.devPlatform`). This is the ONLY channel for operator + * settings — no env-var name reaches this file, and the caller passes one + * argument instead of the ~20 loose values it used to thread through. + */ + config: DevPlatformConfig; /** Absolute path to the built shim entry (`dev-runner-shim/dist/src/index.js`). */ shimEntry: string; - // --- W1 keystones: daemon job-policy endpoint + LLM proxy (spec §4/§6b) ---- - /** `DEV_RUNNER_DAEMON_TOKEN` — the daemon's shared bearer for the internal - * job-policy endpoint AND the DockerBackend's control-plane calls. Absent ⇒ - * that endpoint 503s and the DockerBackend is not registered. */ - daemonToken?: string; - /** `DEV_RUNNER_DAEMON_URL` — the daemon control-plane origin the DockerBackend - * calls (spec §4/§5). Absent ⇒ no DockerBackend (nothing to talk to). */ - daemonUrl?: string; - /** `DEV_PLATFORM_BACKEND` (spec §5). `docker` registers the container backend - * when a daemon URL + token are present; `local` skips it. Default `docker`. */ - backend?: 'docker' | 'local'; - /** `DEV_JOB_LEASE_TTL_SEC` — lease TTL a docker job requests + renews at - * ~TTL/3 (spec §7/§8). Default 180 in the backend. */ - leaseTtlSec?: number; - /** Digest-pinned runner image (`DEV_RUNNER_DEFAULT_IMAGE`). Absent ⇒ the - * job-policy endpoint 503s (nothing to derive an image from). */ - runnerImage?: string; - /** Operator egress default (`DEV_EGRESS_BASE_ALLOWLIST`). */ - egressBaseAllowlist?: readonly string[]; - /** Hostname the job container reaches the middleware on. Defaults to the host - * of `baseUrl`. */ - middlewareHost?: string; - /** LLM-proxy config (spec §6b). The proxy router is ALWAYS mounted (its `GET /` - * probe must answer 2xx); these tune the model gate + upstream. */ - llm?: { - /** Vault provider segment. Default `anthropic`. */ - provider?: string; - /** Upstream origin. Default `https://api.anthropic.com`. */ - upstreamBaseUrl?: string; - /** Exact model ids a job may call. Empty/absent ⇒ the proxy 500s "no policy". */ - allowedModels?: readonly string[]; + // --- test seams for the LLM proxy (spec §6b) ------------------------------ + /** Non-operator overrides for the always-mounted LLM proxy. Nothing here comes + * from env; the real boot passes none of it. */ + llmSeams?: { /** `ANTHROPIC_BASE_URL` handed to api_key jobs. Defaults to `/api/v1/dev-runner/llm`. */ proxyBaseUrl?: string; - /** W4 (spec §5): per-job cost budget default applied when neither the job nor - * its repo sets one (`DEV_JOB_DEFAULT_BUDGET_USD`). */ - defaultBudgetCostUsd?: number; - /** W4 (spec §5, Forge #2): the `max_tokens` clamp ceiling the proxy enforces so - * the buffered budget path cannot overshoot on a single response. */ - maxOutputTokens?: number; - /** Test seams. */ fetchImpl?: typeof fetch; resolvePolicy?: (agentKind: string) => Promise; resolveProviderKey?: (provider: string) => Promise; onAccountingError?: (err: unknown, ctx: { jobId: string; tokensIn: number; tokensOut: number }) => void; }; - // --- W4 keystone: the Fly Machines runner backend (spec §2) --------------- - /** `FlyMachinesBackend` config. Present ⇒ the backend is registered (one - * ephemeral Fly Machine per job in a DEDICATED runner app); absent ⇒ not - * registered (like the DockerBackend keys on the daemon url). The apiBase + - * phoneHomeUrl are RESOLVED by the caller (on-/off-Fly selection) and are - * DELIBERATELY not SSRF-guarded — they are operator URLs (`.internal` on Fly). */ - fly?: { - /** `DEV_FLY_RUNNER_APP` — the dedicated runner app, NEVER odoo-bot-middleware. */ - runnerApp: string; - /** Machines API root, resolved on-/off-Fly by the caller. */ - apiBase: string; - /** Digest-pinned runner image (`DEV_RUNNER_IMAGE`, fallback DEV_RUNNER_DEFAULT_IMAGE). */ - image: string; - /** Shim phone-home URL, resolved on-/off-Fly by the caller. */ - phoneHomeUrl: string; - /** Default guest size a machine boots with (clamped to the ceilings below). */ - guest: FlyGuest; - /** `DEV_FLY_MAX_CPUS` ceiling. */ - maxCpus: number; - /** `DEV_FLY_MAX_MEMORY_MB` ceiling. */ - maxMemoryMb: number; - /** Optional Fly region placement. */ - region?: string; - /** Test seams. */ + // --- test seams for the Fly Machines backend (spec §2) -------------------- + /** Non-operator overrides for `FlyMachinesBackend`. Whether the backend is + * registered at all is decided from `config.fly` + `config.runnerImage`. */ + flySeams?: { fetchImpl?: typeof fetch; /** Deploy-token provider override (tests inject; default reads Vault). */ resolveDeployToken?: () => Promise; @@ -191,6 +133,10 @@ export interface WireDevPlatformDeps { githubAppFetch?: TokenFetch; now?: () => Date; log?: (msg: string) => void; + /** Operator-facing WARNINGS (a misconfigured Fly runner app). Defaults to + * `console.warn` on purpose: these are safety refusals, and a silently + * swallowed one is worse than a noisy test. */ + warn?: (msg: string) => void; } export interface WiredDevPlatform { @@ -234,8 +180,14 @@ export interface WiredDevPlatform { * no side effects, no listening; the caller mounts + starts the worker. */ export function assembleDevPlatform(deps: WireDevPlatformDeps): WiredDevPlatform { const log = deps.log ?? (() => {}); + const warn = deps.warn ?? ((msg: string) => { console.warn(msg); }); + const cfg = deps.config; const apiBaseUrl = deps.githubApiBaseUrl ?? DEFAULT_GITHUB_API_BASE; + // Resolved FIRST so its two refusal warnings are the earliest thing this + // assembly can emit — the position they held when the caller did this work. + const flyBackendConfig = resolveFlyBackendConfig(cfg, warn); + const eventBus = new DevJobEventBus(); const jobStore = new DevJobStore(deps.pool, { eventBus }); const repoStore = new DevRepoStore(deps.pool); @@ -319,13 +271,13 @@ export function assembleDevPlatform(deps: WireDevPlatformDeps): WiredDevPlatform if (!token) throw new Error(`devplatform.repo_not_connected: ${repo.owner}/${repo.name}`); const service = new DiffApplyService({ forge: forgeFactory(token), - author: parseGitIdentity(deps.commitAuthor), + author: parseGitIdentity(cfg.commitAuthor), }); return service.apply(input); }, }; - const backends = deps.backends ?? buildBackends(deps, log, jobStore); + const backends = deps.backends ?? buildBackends(deps, flyBackendConfig, log, jobStore); // The durable human-gate table (spec §5). Created BEFORE the worker so the // diff-policy gate handler can close over it; the W2 phase engine below reuses @@ -412,11 +364,11 @@ export function assembleDevPlatform(deps: WireDevPlatformDeps): WiredDevPlatform const baseSha = await forgeFactory(token).getRef(repo.owner, repo.name, repo.defaultBranch); return jobStore.prepareProvision(job, lease, baseSha); }, - baseUrl: deps.baseUrl, - maxConcurrent: deps.maxConcurrentJobs, - wallClockMs: deps.wallClockMs, - heartbeatTimeoutMs: deps.heartbeatTimeoutMs, - subscriptionModeEnabled: deps.subscriptionModeEnabled, + baseUrl: cfg.baseUrl, + maxConcurrent: cfg.maxConcurrentJobs, + wallClockMs: cfg.wallClockMs, + heartbeatTimeoutMs: cfg.heartbeatTimeoutMs, + subscriptionModeEnabled: cfg.subscriptionModeEnabled, log, }); @@ -495,37 +447,37 @@ export function assembleDevPlatform(deps: WireDevPlatformDeps): WiredDevPlatform makeIssuesTracker: makeIssuesTrackerFactory(apiBaseUrl), finalizeDevJob: boundFinalize, applyJob, - subscriptionModeEnabled: deps.subscriptionModeEnabled, + subscriptionModeEnabled: cfg.subscriptionModeEnabled, ...(deps.deviceFlow ? { deviceFlow: deps.deviceFlow } : {}), log, }); // --- W1 keystones: job-policy config + the always-mounted LLM proxy -------- - const middlewareHost = deps.middlewareHost ?? hostOf(deps.baseUrl); + const middlewareHost = cfg.middlewareHost ?? hostOf(cfg.baseUrl); const llmProxyBaseUrl = - deps.llm?.proxyBaseUrl ?? `${deps.baseUrl.replace(/\/+$/, '')}/api/v1/dev-runner/llm`; + deps.llmSeams?.proxyBaseUrl ?? `${cfg.baseUrl.replace(/\/+$/, '')}/api/v1/dev-runner/llm`; // Present ONLY when a runner image is configured; otherwise the internal // job-policy endpoint 503s (there is no image to derive), matching its contract. - const jobPolicyConfig: DeriveJobPolicyConfig | undefined = deps.runnerImage + const jobPolicyConfig: DeriveJobPolicyConfig | undefined = cfg.runnerImage ? { middlewareHost, - baseAllowlist: deps.egressBaseAllowlist ?? [], - image: deps.runnerImage, + baseAllowlist: cfg.egressBaseAllowlist ?? [], + image: cfg.runnerImage, llmProxyBaseUrl, } : undefined; - const llmProvider = deps.llm?.provider ?? DEFAULT_LLM_PROVIDER; - const llmUpstreamBaseUrl = deps.llm?.upstreamBaseUrl ?? DEFAULT_LLM_UPSTREAM_BASE_URL; - const llmAllowedModels = deps.llm?.allowedModels ?? []; + const llmProvider = cfg.llm?.provider ?? DEFAULT_LLM_PROVIDER; + const llmUpstreamBaseUrl = cfg.llm?.upstreamBaseUrl ?? DEFAULT_LLM_UPSTREAM_BASE_URL; + const llmAllowedModels = cfg.llm?.allowedModels ?? []; const resolvePolicy = - deps.llm?.resolvePolicy ?? + deps.llmSeams?.resolvePolicy ?? (async (): Promise => llmAllowedModels.length === 0 ? null // unconfigured ⇒ proxy answers 500 "no LLM policy" : { provider: llmProvider, upstreamBaseUrl: llmUpstreamBaseUrl, allowedModels: llmAllowedModels }); const resolveProviderKey = - deps.llm?.resolveProviderKey ?? + deps.llmSeams?.resolveProviderKey ?? ((provider: string) => deps.vault.get(DEV_PLATFORM_VAULT_AGENT, `llm/${provider}/api_key`)); // W4 (spec §5, Forge #3): every allowed model MUST have a price-table entry, else @@ -558,10 +510,10 @@ export function assembleDevPlatform(deps: WireDevPlatformDeps): WiredDevPlatform // event log the runner streams (metadata only, never a token/prompt). emitBudgetWarning: (jobId, info) => jobStore.appendHostEvent(jobId, 'budget_warning', { ...info }).then(() => undefined), - defaultBudgetCostUsd: deps.llm?.defaultBudgetCostUsd ?? DEFAULT_JOB_BUDGET_USD, + defaultBudgetCostUsd: cfg.llm?.defaultBudgetCostUsd ?? DEFAULT_JOB_BUDGET_USD, // REQUIRED (Forge #2): clamp `max_tokens` so the buffered enforcement path is // bounded; always supplied so the ceiling is never left open. - maxOutputTokens: deps.llm?.maxOutputTokens ?? DEFAULT_LLM_MAX_OUTPUT_TOKENS, + maxOutputTokens: cfg.llm?.maxOutputTokens ?? DEFAULT_LLM_MAX_OUTPUT_TOKENS, log, }); @@ -571,8 +523,8 @@ export function assembleDevPlatform(deps: WireDevPlatformDeps): WiredDevPlatform resolveProviderKey, addJobUsage: (jobId, tokensIn, tokensOut) => jobStore.addJobUsage(jobId, tokensIn, tokensOut), budget: budgetHook, - ...(deps.llm?.fetchImpl ? { fetchImpl: deps.llm.fetchImpl } : {}), - ...(deps.llm?.onAccountingError ? { onAccountingError: deps.llm.onAccountingError } : {}), + ...(deps.llmSeams?.fetchImpl ? { fetchImpl: deps.llmSeams.fetchImpl } : {}), + ...(deps.llmSeams?.onAccountingError ? { onAccountingError: deps.llmSeams.onAccountingError } : {}), log, }); @@ -581,8 +533,8 @@ export function assembleDevPlatform(deps: WireDevPlatformDeps): WiredDevPlatform repos: repoStore, scmTokens: scopedScmTokens, finalizeDevJob: boundFinalize, - wallClockMs: deps.wallClockMs, - ...(deps.daemonToken ? { daemonToken: deps.daemonToken } : {}), + wallClockMs: cfg.wallClockMs, + ...(cfg.daemonToken ? { daemonToken: cfg.daemonToken } : {}), ...(jobPolicyConfig ? { jobPolicyConfig } : {}), llmProxyRouter, // W2: the phase-result endpoint is mounted now that the engine exists. @@ -834,18 +786,20 @@ export function mountDevPlatform( function buildBackends( deps: WireDevPlatformDeps, + fly: ResolvedFlyBackendConfig | undefined, log: (msg: string) => void, jobStore: DevJobStore, ): readonly RunnerBackend[] { + const cfg = deps.config; const backends: RunnerBackend[] = []; // W4 hosted path: the FlyMachinesBackend, registered ONLY when a dedicated runner // app is configured (DEV_FLY_RUNNER_APP) — without it there is no app to launch // machines in, so it stays unregistered rather than throwing at boot (same secure // default as the DockerBackend keying on the daemon url). The deploy token is read - // from Vault per call; apiBase/phoneHomeUrl are resolved on-/off-Fly by the caller. - if (deps.fly) { - const fly = deps.fly; + // from Vault per call; apiBase/phoneHomeUrl are resolved on-/off-Fly by + // `resolveFlyBackendConfig`, which also owns the two refusal warnings. + if (fly) { backends.push( new FlyMachinesBackend({ apiBase: fly.apiBase, @@ -853,7 +807,7 @@ function buildBackends( // Read the deploy token from Vault per API operation — never held on the // instance. A test may inject `resolveDeployToken` instead. token: - fly.resolveDeployToken ?? + deps.flySeams?.resolveDeployToken ?? (async () => { const tok = await deps.vault.get(DEV_PLATFORM_VAULT_AGENT, FLY_DEPLOY_TOKEN_VAULT_KEY); if (!tok) { @@ -876,7 +830,7 @@ function buildBackends( return job !== null && !isTerminalDevJobStatus(job.status); }, ...(fly.region ? { region: fly.region } : {}), - ...(fly.fetchImpl ? { fetchImpl: fly.fetchImpl } : {}), + ...(deps.flySeams?.fetchImpl ? { fetchImpl: deps.flySeams.fetchImpl } : {}), log, }), ); @@ -893,12 +847,12 @@ function buildBackends( // (the default) and registered ONLY when a daemon URL + token are configured — // without both there is nothing to talk to, so it stays unregistered rather // than throwing at boot (secure default: off until the operator sets the token). - if ((deps.backend ?? 'docker') === 'docker' && deps.daemonUrl && deps.daemonToken) { + if (cfg.backend === 'docker' && cfg.daemonUrl && cfg.daemonToken) { backends.push( new DockerBackend({ - daemonUrl: deps.daemonUrl, - daemonToken: deps.daemonToken, - ...(deps.leaseTtlSec !== undefined ? { leaseTtlSec: deps.leaseTtlSec } : {}), + daemonUrl: cfg.daemonUrl, + daemonToken: cfg.daemonToken, + ...(cfg.leaseTtlSec !== undefined ? { leaseTtlSec: cfg.leaseTtlSec } : {}), log, }), ); @@ -909,14 +863,14 @@ function buildBackends( // acknowledged the jail (DEV_PLATFORM_UNSAFE_LOCAL). W1 demotes it to an escape // hatch so it never becomes the permanent crutch the epic names as a risk. The // backend constructor enforces the uid; config's boot refusal guarantees it. - if (deps.unsafeLocal) { + if (cfg.unsafeLocal) { backends.push( new LocalProcessBackend({ unsafeLocalAck: true, - localUid: deps.localUid ?? 0, - workspaceDir: deps.workspaceDir, + localUid: cfg.localUid ?? 0, + workspaceDir: cfg.workspaceDir, shimEntry: deps.shimEntry, - cliBin: deps.cliBin, + cliBin: cfg.cliBin, log, }), ); @@ -925,6 +879,77 @@ function buildBackends( return backends; } +/** + * The `FlyMachinesBackend` inputs, resolved from the operator's config: the + * on-/off-Fly endpoint selection plus the two safety refusals. Present ⇒ the + * backend is registered; `undefined` ⇒ it is not (the secure default, exactly as + * the DockerBackend keys on its daemon url). + * + * `apiBase`/`phoneHomeUrl` are DELIBERATELY not SSRF-guarded — they are operator + * URLs, and `.internal` is the correct value on Fly. + */ +interface ResolvedFlyBackendConfig { + runnerApp: string; + apiBase: string; + image: string; + phoneHomeUrl: string; + guest: FlyGuest; + maxCpus: number; + maxMemoryMb: number; + region?: string | undefined; +} + +/** Fly guest defaults, applied when the operator config omits them. */ +const DEFAULT_FLY_GUEST_CPUS = 1; +const DEFAULT_FLY_GUEST_MEMORY_MB = 1024; +const DEFAULT_FLY_MAX_CPUS = 4; +const DEFAULT_FLY_MAX_MEMORY_MB = 8192; + +function resolveFlyBackendConfig( + cfg: DevPlatformConfig, + warn: (msg: string) => void, +): ResolvedFlyBackendConfig | undefined { + const fly = cfg.fly; + const runnerApp = fly?.runnerApp; + if (!runnerApp) return undefined; + + // The runner app MUST be dedicated — NEVER this middleware's own Fly app, or a + // job's ephemeral machine (running hostile repo code) would be provisioned into + // the app that holds the middleware's machines, volumes, and app-level secrets. + const hostAppName = fly?.hostAppName; + if (hostAppName && runnerApp === hostAppName) { + warn( + `[middleware] DEV_FLY_RUNNER_APP (${runnerApp}) equals this app's FLY_APP_NAME — refusing to provision runners into the middleware's own app; FlyMachinesBackend NOT registered`, + ); + } + if (!cfg.runnerImage) { + warn( + '[middleware] DEV_FLY_RUNNER_APP set but no runner image (DEV_RUNNER_IMAGE / DEV_RUNNER_DEFAULT_IMAGE) — FlyMachinesBackend NOT registered', + ); + return undefined; + } + if (hostAppName && runnerApp === hostAppName) return undefined; + + // On Fly (FLY_APP_NAME injected) use the internal Machines API + a `.internal` + // 6PN phone-home address; off Fly use the public endpoints. + return { + runnerApp, + apiBase: hostAppName ? 'http://_api.internal:4280/v1' : 'https://api.machines.dev/v1', + image: cfg.runnerImage, + phoneHomeUrl: + fly?.phoneHomeUrl ?? + (hostAppName ? `http://${hostAppName}.internal:8080` : (fly?.publicBaseUrl ?? '')), + guest: { + cpus: fly?.guestCpus ?? DEFAULT_FLY_GUEST_CPUS, + memoryMb: fly?.guestMemoryMb ?? DEFAULT_FLY_GUEST_MEMORY_MB, + cpuKind: 'shared', + }, + maxCpus: fly?.maxCpus ?? DEFAULT_FLY_MAX_CPUS, + maxMemoryMb: fly?.maxMemoryMb ?? DEFAULT_FLY_MAX_MEMORY_MB, + ...(fly?.region ? { region: fly.region } : {}), + }; +} + /** Adapt the repo-bound `GithubIssuesTracker` to the route's `DevPlatformTracker` * (which carries the repo, so `getTicket(n)` binds it here). */ function makeIssuesTrackerFactory( diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 88547f3f..57e03b11 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -166,8 +166,8 @@ import { createRequireAuth } from './auth/requireAuth.js'; import { createOperatorAuthAccessor } from './auth/operatorAuthAccessor.js'; import { assembleDevPlatform, mountDevPlatform } from './devplatform/wireDevPlatform.js'; import { createChatDevJobOrchestratorTools } from './devplatform/chatDevJobToolWiring.js'; -import { isPermittedLauncher } from './routes/devPlatformShared.js'; -import { createDevWebhooksRouter, type DevWebhooksRouterDeps } from './routes/devWebhooks.js'; +import { isPermittedLauncher } from './devplatform/routes/devPlatformShared.js'; +import { createDevWebhooksRouter, type DevWebhooksRouterDeps } from './devplatform/routes/devWebhooks.js'; import { WebhookDeliveryStore } from './devplatform/triggers/webhookDeliveryStore.js'; import { DevGithubAppStore } from './devplatform/githubApp/appStore.js'; import { @@ -232,7 +232,7 @@ import { FileInstalledRegistry } from './plugins/fileInstalledRegistry.js'; import { InstallService } from './plugins/installService.js'; import { registerInstalledPluginTemplates } from './plugins/pluginTemplates.js'; import type { PluginTemplateRegistrar } from './plugins/pluginTemplates.js'; -import { createDevPlatformGithubAppRouter } from './routes/devPlatformGithubApp.js'; +import { createDevPlatformGithubAppRouter } from './devplatform/routes/devPlatformGithubApp.js'; import { OAuthBrokerService, PendingFlowStore, @@ -2095,7 +2095,7 @@ async function main(): Promise { // kill switch. The webhook stores are pool/vault-backed and stateless, so building // them here (before the full platform assembly) is safe and keeps the mount order // correct; the worker (assembled later) claims the created jobs from the DB. - if (config.DEV_PLATFORM_ENABLED && graphPool && config.DEV_WEBHOOKS_ENABLED) { + if (config.devPlatform.enabled && graphPool && config.devPlatform.webhooks.enabled) { const webhookAppStore = new DevGithubAppStore(graphPool, secretVault); const webhookRepoStore = new DevRepoStoreForWebhooks(graphPool); const webhookJobStore = new DevJobStoreForWebhooks(graphPool); @@ -2128,7 +2128,7 @@ async function main(): Promise { // Webhook jobs run on the non-local default backend: Fly when a runner app is // configured, else the docker shipping path. `local` is structurally refused by // the trigger job service, so it is never selected here. - const webhookBackend = config.DEV_FLY_RUNNER_APP ? ('fly' as const) : ('docker' as const); + const webhookBackend = config.devPlatform.fly?.runnerApp ? ('fly' as const) : ('docker' as const); const webhookDeps: DevWebhooksRouterDeps = { listWebhookSecrets, @@ -2146,9 +2146,9 @@ async function main(): Promise { ), mintRunnerToken: () => mintDevRunnerToken(), webhookBackend, - webhooksEnabled: config.DEV_WEBHOOKS_ENABLED, - maxJobsPerRepoHour: config.DEV_WEBHOOK_MAX_JOBS_PER_REPO_HOUR, - maxJobsPerSenderHour: config.DEV_WEBHOOK_MAX_JOBS_PER_SENDER_HOUR, + webhooksEnabled: config.devPlatform.webhooks.enabled, + maxJobsPerRepoHour: config.devPlatform.webhooks.maxJobsPerRepoHour, + maxJobsPerSenderHour: config.devPlatform.webhooks.maxJobsPerSenderHour, log: (msg) => console.log(msg), }; app.use(createDevWebhooksRouter(webhookDeps)); @@ -2643,116 +2643,26 @@ async function main(): Promise { // spine + repo/artifact tables live there); in-memory mode has nowhere to // persist a durable queue. The two safety-critical modes (subscription auth, // unsafe-local backend) already refused boot in config.ts if misconfigured. - if (config.DEV_PLATFORM_ENABLED && graphPool) { + if (config.devPlatform.enabled && graphPool) { const devPlatformGithubDeviceProvider = createGitHubDeviceProvider( - config.DEV_PLATFORM_GITHUB_CLIENT_ID ?? config.GITHUB_OAUTH_CLIENT_ID, + config.devPlatform.githubClientId, ); const shimEntry = fileURLToPath( new URL('../packages/dev-runner-shim/dist/src/index.js', import.meta.url), ); - // Comma-separated env list → trimmed non-empty entries (egress allowlist, - // model allowlist). Entry-level validation happens in deriveJobPolicy. - const csvList = (raw: string): string[] => - raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0); // W2: role-principal gates resolve their live holder set against the same // conductor role store the conductor await gate uses. const devPlatformRoleStore = new ConductorRoleStore(graphPool); - // The runner image, shared by every backend: FlyMachinesBackend (below) AND - // the DockerBackend job-policy config (assembleDevPlatform's `runnerImage`, - // further down) both derive from this one resolution. `DEV_RUNNER_IMAGE` - // wins when set (it's the name the daemon's own DEV_RUNNER_IMAGES/allowlist - // config uses too, so one operator-set var keeps every side in agreement); - // `DEV_RUNNER_DEFAULT_IMAGE` is the fallback. A digest-pinned image is - // required on Fly (enforced below); locally a floating tag is fine. - // - // Epic #470 W4 — the on-/off-Fly selection for the Machines backend lives - // HERE so the assembly layer stays env-free: on Fly (FLY_APP_NAME injected) - // use the internal Machines API + a `.internal` 6PN phone-home address; off - // Fly use the public endpoints. These operator URLs are DELIBERATELY not - // SSRF-guarded (`.internal` is valid here). - const resolvedRunnerImage = config.DEV_RUNNER_IMAGE ?? config.DEV_RUNNER_DEFAULT_IMAGE; - // The runner app MUST be dedicated — NEVER this middleware's own Fly app, or a - // job's ephemeral machine (running hostile repo code) would be provisioned into - // the app that holds the middleware's machines, volumes, and app-level secrets - // (Forge W4 wiring audit — the "dedicated app" invariant was comment-only). - const flyAppIsSelf = Boolean( - config.DEV_FLY_RUNNER_APP && config.FLY_APP_NAME && config.DEV_FLY_RUNNER_APP === config.FLY_APP_NAME, - ); - if (flyAppIsSelf) { - console.warn( - `[middleware] DEV_FLY_RUNNER_APP (${config.DEV_FLY_RUNNER_APP}) equals this app's FLY_APP_NAME — refusing to provision runners into the middleware's own app; FlyMachinesBackend NOT registered`, - ); - } - const flyConfig = - config.DEV_FLY_RUNNER_APP && resolvedRunnerImage && !flyAppIsSelf - ? { - runnerApp: config.DEV_FLY_RUNNER_APP, - apiBase: config.FLY_APP_NAME - ? 'http://_api.internal:4280/v1' - : 'https://api.machines.dev/v1', - image: resolvedRunnerImage, - phoneHomeUrl: - config.DEV_FLY_PHONE_HOME_URL ?? - (config.FLY_APP_NAME - ? `http://${config.FLY_APP_NAME}.internal:8080` - : config.PUBLIC_BASE_URL), - guest: { - cpus: config.DEV_FLY_GUEST_CPUS, - memoryMb: config.DEV_FLY_GUEST_MEMORY_MB, - cpuKind: 'shared', - }, - maxCpus: config.DEV_FLY_MAX_CPUS, - maxMemoryMb: config.DEV_FLY_MAX_MEMORY_MB, - ...(config.DEV_FLY_REGION ? { region: config.DEV_FLY_REGION } : {}), - } - : undefined; - if (config.DEV_FLY_RUNNER_APP && !resolvedRunnerImage) { - console.warn( - '[middleware] DEV_FLY_RUNNER_APP set but no runner image (DEV_RUNNER_IMAGE / DEV_RUNNER_DEFAULT_IMAGE) — FlyMachinesBackend NOT registered', - ); - } + // Epic #470 C3: ONE namespaced config object, built in config.ts. Everything + // operator-settable — the runner image fallback, the on-/off-Fly selection, + // the comma-separated lists — is resolved behind that boundary or inside the + // assembly, so no `DEV_*` env name appears at this call site any more. const wiredDevPlatform = assembleDevPlatform({ pool: graphPool, vault: secretVault, + config: config.devPlatform, resolveRoleHolders: (key) => devPlatformRoleStore.resolve(key), - baseUrl: config.DEV_PLATFORM_RUNNER_BASE_URL ?? `http://127.0.0.1:${String(config.PORT)}`, - cliBin: config.DEV_PLATFORM_CLI_BIN, - wallClockMs: config.DEV_PLATFORM_JOB_WALL_CLOCK_MS, - heartbeatTimeoutMs: config.DEV_PLATFORM_HEARTBEAT_TIMEOUT_MS, - maxConcurrentJobs: config.DEV_PLATFORM_MAX_CONCURRENT_JOBS, - commitAuthor: config.DEV_PLATFORM_COMMIT_AUTHOR, - subscriptionModeEnabled: config.DEV_PLATFORM_SUBSCRIPTION_MODE, - workspaceDir: config.DEV_PLATFORM_WORKSPACE_DIR, - unsafeLocal: config.DEV_PLATFORM_UNSAFE_LOCAL, - ...(config.DEV_PLATFORM_LOCAL_UID !== undefined ? { localUid: config.DEV_PLATFORM_LOCAL_UID } : {}), shimEntry, - // W1 keystones (spec §4/§6b): the daemon job-policy endpoint + the LLM - // proxy. Absent daemon token / runner image ⇒ the internal endpoint 503s; - // the LLM proxy is always mounted (its origin probe must answer 2xx). - ...(config.DEV_RUNNER_DAEMON_TOKEN ? { daemonToken: config.DEV_RUNNER_DAEMON_TOKEN } : {}), - ...(config.DEV_RUNNER_DAEMON_URL ? { daemonUrl: config.DEV_RUNNER_DAEMON_URL } : {}), - backend: config.DEV_PLATFORM_BACKEND, - leaseTtlSec: config.DEV_JOB_LEASE_TTL_SEC, - ...(resolvedRunnerImage ? { runnerImage: resolvedRunnerImage } : {}), - ...(config.DEV_EGRESS_BASE_ALLOWLIST - ? { egressBaseAllowlist: csvList(config.DEV_EGRESS_BASE_ALLOWLIST) } - : {}), - ...(config.DEV_PLATFORM_MIDDLEWARE_HOST - ? { middlewareHost: config.DEV_PLATFORM_MIDDLEWARE_HOST } - : {}), - llm: { - provider: config.DEV_PLATFORM_LLM_PROVIDER, - upstreamBaseUrl: config.DEV_PLATFORM_LLM_UPSTREAM_BASE_URL, - allowedModels: config.DEV_PLATFORM_LLM_ALLOWED_MODELS - ? csvList(config.DEV_PLATFORM_LLM_ALLOWED_MODELS) - : [], - // W4 (spec §5): the budget hook's config default + the max_tokens clamp ceiling. - defaultBudgetCostUsd: config.DEV_JOB_DEFAULT_BUDGET_USD, - maxOutputTokens: config.DEV_JOB_MAX_OUTPUT_TOKENS, - }, - // W4 (spec §2): the Fly Machines backend, present only when a dedicated runner - // app is configured (absent ⇒ not registered). - ...(flyConfig ? { fly: flyConfig } : {}), ...(devPlatformGithubDeviceProvider ? { deviceFlow: { @@ -2803,7 +2713,7 @@ async function main(): Promise { repoStore: wiredDevPlatform.repoStore, jobStore: wiredDevPlatform.jobStore, isPermittedLauncher, - defaultBackend: config.DEV_PLATFORM_BACKEND, + defaultBackend: config.devPlatform.backend, getCallerUserId: () => turnContext.current()?.userId, }); for (const reg of chatDevJobTools.registrations) { @@ -2831,7 +2741,7 @@ async function main(): Promise { process.once('SIGTERM', stopDevPlatformWorker); process.once('SIGINT', stopDevPlatformWorker); console.log( - `[middleware] dev platform ENABLED — worker running (max ${String(config.DEV_PLATFORM_MAX_CONCURRENT_JOBS)} concurrent, ${String(wiredDevPlatform.backends.length)} backend(s))`, + `[middleware] dev platform ENABLED — worker running (max ${String(config.devPlatform.maxConcurrentJobs)} concurrent, ${String(wiredDevPlatform.backends.length)} backend(s))`, ); // Contribute the operator menu entry instead of hardcoding it in the @@ -2859,8 +2769,8 @@ async function main(): Promise { // cron only prunes aged rows. Terminal-job purge stays operator-driven via // `scripts/dev-transcript.ts purge`. `overlap:'skip'` so a slow run never stacks. const devRetention = new DevRetentionRunner(graphPool, { - eventRetentionDays: config.DEV_PLATFORM_EVENT_RETENTION_DAYS, - auditRetentionDays: config.DEV_PLATFORM_AUDIT_RETENTION_DAYS, + eventRetentionDays: config.devPlatform.retention.eventRetentionDays, + auditRetentionDays: config.devPlatform.retention.auditRetentionDays, }); jobScheduler.register( 'dev-platform', @@ -2873,7 +2783,7 @@ async function main(): Promise { }, ); console.log('[middleware] dev-retention cron registered (17 3 * * *)'); - } else if (config.DEV_PLATFORM_ENABLED) { + } else if (config.devPlatform.enabled) { console.warn( '[middleware] DEV_PLATFORM_ENABLED=true but no graphPool (in-memory KG backend) — dev platform NOT started; set DATABASE_URL to enable', ); diff --git a/middleware/src/routes/conductorWebhooksInbound.ts b/middleware/src/routes/conductorWebhooksInbound.ts index 3668dd76..eb45c6bc 100644 --- a/middleware/src/routes/conductorWebhooksInbound.ts +++ b/middleware/src/routes/conductorWebhooksInbound.ts @@ -1,7 +1,7 @@ /** * Issue #437 — inbound Conductor webhooks (`POST /api/hooks/:endpointId`). * - * MOUNTING CONTRACT (mirrors `routes/devWebhooks.ts`): this router MUST be mounted + * MOUNTING CONTRACT (mirrors `devplatform/routes/devWebhooks.ts`): this router MUST be mounted * BEFORE the global `app.use(express.json(...))`. HMAC verification needs the RAW * request bytes; once `express.json` has parsed and re-serialised the body, those * bytes are gone and every signature check fails. The router attaches its OWN diff --git a/middleware/test/devplatform/chatDevJobService.pg.test.ts b/middleware/test/devplatform/chatDevJobService.pg.test.ts index 93bc3fe9..52b506f4 100644 --- a/middleware/test/devplatform/chatDevJobService.pg.test.ts +++ b/middleware/test/devplatform/chatDevJobService.pg.test.ts @@ -12,7 +12,7 @@ import { createChatDevJobService } from '../../src/devplatform/chatDevJobService import { DevJobEventBus } from '../../src/devplatform/devJobEventBus.js'; import { DevJobStore } from '../../src/devplatform/devJobStore.js'; import { DevRepoStore } from '../../src/devplatform/devRepoStore.js'; -import { isPermittedLauncher } from '../../src/routes/devPlatformShared.js'; +import { isPermittedLauncher } from '../../src/devplatform/routes/devPlatformShared.js'; import type { DevRepo } from '../../src/devplatform/types.js'; /** diff --git a/middleware/test/devplatform/chatDevJobToolWiring.test.ts b/middleware/test/devplatform/chatDevJobToolWiring.test.ts index 42a9acdb..9171e078 100644 --- a/middleware/test/devplatform/chatDevJobToolWiring.test.ts +++ b/middleware/test/devplatform/chatDevJobToolWiring.test.ts @@ -8,7 +8,7 @@ import { DEV_JOB_STATUS_TOOL_NAME, type KernelToolRegistration, } from '../../src/devplatform/devJobOrchestratorTool.js'; -import { isPermittedLauncher } from '../../src/routes/devPlatformShared.js'; +import { isPermittedLauncher } from '../../src/devplatform/routes/devPlatformShared.js'; import type { ChatDevJobJobStore, ChatDevJobRepoStore, diff --git a/middleware/test/devplatform/deriveJobPolicy.test.ts b/middleware/test/devplatform/deriveJobPolicy.test.ts index 2cf2d5eb..bbef1446 100644 --- a/middleware/test/devplatform/deriveJobPolicy.test.ts +++ b/middleware/test/devplatform/deriveJobPolicy.test.ts @@ -11,7 +11,7 @@ import { type DeriveJobPolicyConfig, type JobPolicyRepoInput, } from '../../src/devplatform/deriveJobPolicy.js'; -import { createDevRunnerRouter } from '../../src/routes/devRunnerApi.js'; +import { createDevRunnerRouter } from '../../src/devplatform/routes/devRunnerApi.js'; import type { DevJobStatus, DevRepo } from '../../src/devplatform/types.js'; import { FakeStore, makeJob, auth, hasCredentialKey } from './devRunnerApi.harness.js'; diff --git a/middleware/test/devplatform/devJobOrchestratorTool.test.ts b/middleware/test/devplatform/devJobOrchestratorTool.test.ts index 692c0950..902be0b7 100644 --- a/middleware/test/devplatform/devJobOrchestratorTool.test.ts +++ b/middleware/test/devplatform/devJobOrchestratorTool.test.ts @@ -22,7 +22,7 @@ import { type ChatDevJobService, type DevJobStatusResult, } from '../../src/devplatform/devJobOrchestratorTool.js'; -import { isPermittedLauncher } from '../../src/routes/devPlatformShared.js'; +import { isPermittedLauncher } from '../../src/devplatform/routes/devPlatformShared.js'; import type { DevJob, DevJobEvent, diff --git a/middleware/test/devplatform/devPlatform.e2e.test.ts b/middleware/test/devplatform/devPlatform.e2e.test.ts index e801b1f6..5da56914 100644 --- a/middleware/test/devplatform/devPlatform.e2e.test.ts +++ b/middleware/test/devplatform/devPlatform.e2e.test.ts @@ -18,6 +18,7 @@ import { DevRepoCredentialStore } from '../../src/devplatform/devRepoCredentials import { mintRunnerToken } from '../../src/devplatform/jobToken.js'; import { publicPaths } from '../../src/auth/publicPaths.js'; import { assembleDevPlatform, mountDevPlatform } from '../../src/devplatform/wireDevPlatform.js'; +import { devPlatformTestConfig } from './devPlatformConfig.harness.js'; import { InMemorySecretVault } from '../../src/secrets/vault.js'; import type { ApplyDiffInput, @@ -356,26 +357,23 @@ describe('devplatform e2e (pg)', { skip: !pgAvailable }, () => { wired = assembleDevPlatform({ pool, vault, - baseUrl, - cliBin: 'claude', - wallClockMs: 10 * 60_000, - heartbeatTimeoutMs: 10 * 60_000, - maxConcurrentJobs: 1, - commitAuthor: 'omadia-dev ', - subscriptionModeEnabled: false, - workspaceDir: '/tmp/e2e-dev-jobs', - unsafeLocal: false, + config: devPlatformTestConfig({ + baseUrl, + wallClockMs: 10 * 60_000, + heartbeatTimeoutMs: 10 * 60_000, + workspaceDir: '/tmp/e2e-dev-jobs', + // W1 keystones (spec §4/§6b): the daemon job-policy endpoint + the LLM + // proxy, wired exactly as index.ts does — so this test fails if either is + // left unmounted, instead of the routes silently not existing. + daemonToken: `${E2E_DAEMON_TOKEN},${E2E_DAEMON_TOKEN_OLD}`, + runnerImage: E2E_RUNNER_IMAGE, + egressBaseAllowlist: ['registry.npmjs.org'], + llm: { allowedModels: [E2E_MODEL] }, + }), shimEntry: '/dev/null', backends: [fakeBackend], forgeFactory: () => stubForge, - // W1 keystones (spec §4/§6b): the daemon job-policy endpoint + the LLM - // proxy, wired exactly as index.ts does — so this test fails if either is - // left unmounted, instead of the routes silently not existing. - daemonToken: `${E2E_DAEMON_TOKEN},${E2E_DAEMON_TOKEN_OLD}`, - runnerImage: E2E_RUNNER_IMAGE, - egressBaseAllowlist: ['registry.npmjs.org'], - llm: { - allowedModels: [E2E_MODEL], + llmSeams: { // A fake upstream so a POST reaches the proxy without real network. fetchImpl: (async () => new Response(JSON.stringify({ usage: { input_tokens: 1, output_tokens: 1 } }), { @@ -687,21 +685,16 @@ describe('devplatform — docker rehydration after a middleware restart (pg)', { return assembleDevPlatform({ pool, vault: new InMemorySecretVault(), - baseUrl: 'http://127.0.0.1:3333', - cliBin: 'claude', - wallClockMs: 600_000, - heartbeatTimeoutMs: 600_000, - maxConcurrentJobs: 1, - commitAuthor: 'omadia-dev ', - subscriptionModeEnabled: false, - workspaceDir: '/tmp/rehydrate-e2e', - unsafeLocal: false, + config: devPlatformTestConfig({ + baseUrl: 'http://127.0.0.1:3333', + workspaceDir: '/tmp/rehydrate-e2e', + // A daemon URL that is never dialled: adoption is a pure in-memory read of + // the persisted handle, and we stop the backend before its renew loop ticks. + daemonUrl: 'http://127.0.0.1:1', + daemonToken: 'tok', + runnerImage: 'ghcr.io/byte5ai/omadia-dev-runner@sha256:' + 'a'.repeat(64), + }), shimEntry: '/dev/null', - // A daemon URL that is never dialled: adoption is a pure in-memory read of - // the persisted handle, and we stop the backend before its renew loop ticks. - daemonUrl: 'http://127.0.0.1:1', - daemonToken: 'tok', - runnerImage: 'ghcr.io/byte5ai/omadia-dev-runner@sha256:' + 'a'.repeat(64), log: (m) => logs.push(m), }); } diff --git a/middleware/test/devplatform/devPlatformBootRefusal.test.ts b/middleware/test/devplatform/devPlatformBootRefusal.test.ts new file mode 100644 index 00000000..a0a122d7 --- /dev/null +++ b/middleware/test/devplatform/devPlatformBootRefusal.test.ts @@ -0,0 +1,114 @@ +import { strict as assert } from 'node:assert'; +import { execFile } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +/** + * Epic #470 C3 — the two credential-exposing modes must still REFUSE BOOT after + * the dev-platform config keys were collapsed into `config.devPlatform`. + * + * `devPlatformBootRefusals` is unit-tested directly in devPlatform.e2e.test.ts. + * That proves the function; it does NOT prove the function is still WIRED into + * `loadConfig`, which is exactly what a config refactor can silently break — and + * the failure mode is a middleware that boots happily with the operator's Claude + * credential inside a runner, or an agent running as root. + * + * So this drives the real module: import `src/config.ts` in a child process with + * the dangerous env set, and assert the process dies with the message. Nothing is + * stubbed; the refusal either fires at import or the test fails. + */ + +const middlewareRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const configEntry = resolve(middlewareRoot, 'src', 'config.ts'); + +/** Import `src/config.ts` in a child process with `env` and return what happened. */ +async function importConfigWith(env: Record): Promise<{ + ok: boolean; + output: string; +}> { + try { + await execFileAsync( + process.execPath, + ['--import', 'tsx', '--input-type=module', '-e', `await import(${JSON.stringify(configEntry)});`], + { + cwd: middlewareRoot, + // A clean env: only PATH (tsx needs node) plus what the case under test sets. + // Nothing else can accidentally satisfy — or trip — a refusal. + env: { PATH: process.env['PATH'] ?? '', ...env }, + }, + ); + return { ok: true, output: '' }; + } catch (err) { + const e = err as { stderr?: string; stdout?: string; message?: string }; + return { ok: false, output: `${e.stderr ?? ''}${e.stdout ?? ''}${e.message ?? ''}` }; + } +} + +describe('dev-platform boot refusals are still wired into loadConfig (epic #470 C3)', () => { + it('refuses to boot on DEV_PLATFORM_SUBSCRIPTION_MODE without DEV_PLATFORM_SUBSCRIPTION_ACK', async () => { + const res = await importConfigWith({ DEV_PLATFORM_SUBSCRIPTION_MODE: 'true' }); + assert.equal(res.ok, false, 'importing config must throw, not boot'); + assert.match(res.output, /Invalid configuration/); + assert.match( + res.output, + /DEV_PLATFORM_SUBSCRIPTION_MODE=true requires DEV_PLATFORM_SUBSCRIPTION_ACK to be set/, + ); + }); + + it('boots once the subscription acknowledgment is supplied', async () => { + const res = await importConfigWith({ + DEV_PLATFORM_SUBSCRIPTION_MODE: 'true', + DEV_PLATFORM_SUBSCRIPTION_ACK: 'I understand', + }); + assert.equal(res.ok, true, `config should load; got: ${res.output}`); + }); + + it('refuses to boot on DEV_PLATFORM_UNSAFE_LOCAL without DEV_PLATFORM_LOCAL_UID', async () => { + const res = await importConfigWith({ DEV_PLATFORM_UNSAFE_LOCAL: 'true' }); + assert.equal(res.ok, false, 'importing config must throw, not boot'); + assert.match(res.output, /Invalid configuration/); + assert.match(res.output, /DEV_PLATFORM_UNSAFE_LOCAL=true requires DEV_PLATFORM_LOCAL_UID/); + }); + + it('boots once the unprivileged uid is supplied', async () => { + const res = await importConfigWith({ + DEV_PLATFORM_UNSAFE_LOCAL: 'true', + DEV_PLATFORM_LOCAL_UID: '1500', + }); + assert.equal(res.ok, true, `config should load; got: ${res.output}`); + }); +}); + +describe('the collapsed dev-platform namespace still carries every setting (epic #470 C3)', () => { + it('keeps the post-processing: PORT-derived runner base URL + resolved workspace dir', async () => { + const { config } = await import('../../src/config.js'); + // DEV_PLATFORM_RUNNER_BASE_URL is unset in the test env ⇒ loopback + PORT. + assert.equal(config.devPlatform.baseUrl, `http://127.0.0.1:${String(config.PORT)}`); + // DEV_PLATFORM_WORKSPACE_DIR defaults to an os.tmpdir() path, which resolvePath + // leaves alone because it is already absolute — the invariant is absoluteness. + assert.ok( + config.devPlatform.workspaceDir.startsWith('/'), + `workspaceDir must be absolute, got ${config.devPlatform.workspaceDir}`, + ); + }); + + it('serves the dev-platform settings ONLY through the namespace', () => { + // A stale `config.DEV_*` read would type-error, but a runtime leftover would + // not — assert the raw keys are gone from the object as well. + return import('../../src/config.js').then(({ config }) => { + const raw = config as unknown as Record; + for (const key of ['DEV_PLATFORM_ENABLED', 'DEV_PLATFORM_BACKEND', 'DEV_FLY_RUNNER_APP']) { + assert.equal(raw[key], undefined, `${key} must not be readable off the top-level config`); + } + assert.equal(typeof config.devPlatform.enabled, 'boolean'); + assert.equal(config.devPlatform.backend, 'docker'); + // The lookalike that is NOT dev-platform stays exactly where it was: a core + // key, read off the top-level config by the dev-graph endpoints. + assert.equal(typeof config.DEV_ENDPOINTS_ENABLED, 'boolean'); + }); + }); +}); diff --git a/middleware/test/devplatform/devPlatformConfig.harness.ts b/middleware/test/devplatform/devPlatformConfig.harness.ts new file mode 100644 index 00000000..790c0c13 --- /dev/null +++ b/middleware/test/devplatform/devPlatformConfig.harness.ts @@ -0,0 +1,40 @@ +import type { DevPlatformConfig } from '../../src/devplatform/config.js'; + +/** + * Build a `DevPlatformConfig` for a wire test. + * + * The assembly takes ONE config object (epic #470 C3) instead of ~20 loose + * fields, so a test that used to spread its settings across the deps literal now + * passes `config: devPlatformTestConfig({ ... })`. + * + * The values here are NOT copies of the production zod defaults — they are the + * inert values the wire tests always used, kept in one place so a new field on + * the interface does not have to be added to four call sites. Anything a test + * actually asserts on must be passed explicitly as an override. + */ +export function devPlatformTestConfig( + over: Partial & Pick, +): DevPlatformConfig { + return { + enabled: true, + cliBin: 'claude', + wallClockMs: 600_000, + heartbeatTimeoutMs: 600_000, + maxConcurrentJobs: 1, + commitAuthor: 'omadia-dev ', + subscriptionModeEnabled: false, + workspaceDir: '/tmp/dev-platform-test', + unsafeLocal: false, + backend: 'docker', + // Read only by the mount site in index.ts, never by the assembly — present so + // the object satisfies the interface. + webhooks: { enabled: false, maxJobsPerRepoHour: 5, maxJobsPerSenderHour: 2 }, + retention: { + eventRetentionDays: 30, + auditRetentionDays: 365, + maxEventsPerJob: 50_000, + artifactMaxBytes: 5 * 1024 * 1024, + }, + ...over, + }; +} diff --git a/middleware/test/devplatform/devPlatformDeviceFlowWiring.test.ts b/middleware/test/devplatform/devPlatformDeviceFlowWiring.test.ts index b2c55adc..ad2a0cbb 100644 --- a/middleware/test/devplatform/devPlatformDeviceFlowWiring.test.ts +++ b/middleware/test/devplatform/devPlatformDeviceFlowWiring.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { after, describe, it } from 'node:test'; import { DeviceFlowStore } from '../../src/issues/deviceFlowStore.js'; -import type { DevPlatformDeviceFlow } from '../../src/routes/devPlatformShared.js'; +import type { DevPlatformDeviceFlow } from '../../src/devplatform/routes/devPlatformShared.js'; import { authHeaders, makeHarness, postJson } from './devPlatformRoutes.harness.js'; /** diff --git a/middleware/test/devplatform/devPlatformGates.test.ts b/middleware/test/devplatform/devPlatformGates.test.ts index 6ebf7ef1..7d8fe043 100644 --- a/middleware/test/devplatform/devPlatformGates.test.ts +++ b/middleware/test/devplatform/devPlatformGates.test.ts @@ -4,7 +4,7 @@ import { after, describe, it } from 'node:test'; import express, { type RequestHandler } from 'express'; -import { createDevPlatformGatesRouter, type DevPlatformGatesDeps } from '../../src/routes/devPlatformGates.js'; +import { createDevPlatformGatesRouter, type DevPlatformGatesDeps } from '../../src/devplatform/routes/devPlatformGates.js'; import type { DevJobGate, GateAnswer } from '../../src/devplatform/pipeline/gateStore.js'; function gate(over: Partial = {}): DevJobGate { diff --git a/middleware/test/devplatform/devPlatformGithubApp.test.ts b/middleware/test/devplatform/devPlatformGithubApp.test.ts index c79c6b82..edac46f5 100644 --- a/middleware/test/devplatform/devPlatformGithubApp.test.ts +++ b/middleware/test/devplatform/devPlatformGithubApp.test.ts @@ -10,7 +10,7 @@ import { createDevPlatformGithubAppRouter, type DevPlatformGithubAppDeps, type GithubAppStorePort, -} from '../../src/routes/devPlatformGithubApp.js'; +} from '../../src/devplatform/routes/devPlatformGithubApp.js'; import { ManifestFlowStore } from '../../src/devplatform/githubApp/manifestFlow.js'; import type { DevGithubApp, diff --git a/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts b/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts index 715bd42e..4d7077a6 100644 --- a/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts +++ b/middleware/test/devplatform/devPlatformPipeline.wire.pg.test.ts @@ -9,6 +9,7 @@ import { Pool } from 'pg'; import { runMultiOrchestratorMigrations } from '@omadia/orchestrator'; import { assembleDevPlatform, mountDevPlatform } from '../../src/devplatform/wireDevPlatform.js'; +import { devPlatformTestConfig } from './devPlatformConfig.harness.js'; import { DevGithubAppStore } from '../../src/devplatform/githubApp/appStore.js'; import { DevJobGateStore } from '../../src/devplatform/pipeline/gateStore.js'; import { DevRepoStore } from '../../src/devplatform/devRepoStore.js'; @@ -140,15 +141,10 @@ describe('dev-platform wiring — a real gated job, end to end through the assem wired = assembleDevPlatform({ pool, vault, - baseUrl: 'http://127.0.0.1:3333', - cliBin: 'claude', - wallClockMs: 600_000, - heartbeatTimeoutMs: 600_000, - maxConcurrentJobs: 1, - commitAuthor: 'omadia-dev ', - subscriptionModeEnabled: false, - workspaceDir: '/tmp/gate-wire', - unsafeLocal: false, + config: devPlatformTestConfig({ + baseUrl: 'http://127.0.0.1:3333', + workspaceDir: '/tmp/gate-wire', + }), shimEntry: '/dev/null', // No backends started — this test drives the phone-home router by hand and // provisions jobs directly (never calls wired.start(), so the claim worker diff --git a/middleware/test/devplatform/devPlatformRoutes.harness.ts b/middleware/test/devplatform/devPlatformRoutes.harness.ts index e91b98cd..3f64bfb3 100644 --- a/middleware/test/devplatform/devPlatformRoutes.harness.ts +++ b/middleware/test/devplatform/devPlatformRoutes.harness.ts @@ -10,7 +10,7 @@ import { assertAuthModeAdmissible, assertLocalBackendAdmissible, type DevPlatformRouterDeps, -} from '../../src/routes/devPlatform.js'; +} from '../../src/devplatform/routes/devPlatform.js'; import { DevJobEventBus } from '../../src/devplatform/devJobEventBus.js'; import type { Ticket } from '../../src/devplatform/githubIssuesTracker.js'; import type { FinalizeContext } from '../../src/devplatform/finalizeDevJob.js'; diff --git a/middleware/test/devplatform/devPlatformRoutes.test.ts b/middleware/test/devplatform/devPlatformRoutes.test.ts index ce87c27e..fd740ff9 100644 --- a/middleware/test/devplatform/devPlatformRoutes.test.ts +++ b/middleware/test/devplatform/devPlatformRoutes.test.ts @@ -11,7 +11,7 @@ import { strict as assert } from 'node:assert'; import { assertAuthModeAdmissible, assertLocalBackendAdmissible, -} from '../../src/routes/devPlatformShared.js'; +} from '../../src/devplatform/routes/devPlatformShared.js'; import { DEVICE_TOKEN, diff --git a/middleware/test/devplatform/devRunnerApi.harness.ts b/middleware/test/devplatform/devRunnerApi.harness.ts index 302ddd9e..c7336650 100644 --- a/middleware/test/devplatform/devRunnerApi.harness.ts +++ b/middleware/test/devplatform/devRunnerApi.harness.ts @@ -8,7 +8,7 @@ import { createDevRunnerRouter, type DevRunnerJobStore, type DevRunnerRouterDeps, -} from '../../src/routes/devRunnerApi.js'; +} from '../../src/devplatform/routes/devRunnerApi.js'; import type { RunnerEventInput } from '../../src/devplatform/devJobStore.js'; import type { FinalizeContext } from '../../src/devplatform/finalizeDevJob.js'; import { diff --git a/middleware/test/devplatform/devWebhooks.test.ts b/middleware/test/devplatform/devWebhooks.test.ts index 2353be45..34955047 100644 --- a/middleware/test/devplatform/devWebhooks.test.ts +++ b/middleware/test/devplatform/devWebhooks.test.ts @@ -5,7 +5,7 @@ import { describe, it } from 'node:test'; import express from 'express'; -import { createDevWebhooksRouter, type DevWebhooksRouterDeps } from '../../src/routes/devWebhooks.js'; +import { createDevWebhooksRouter, type DevWebhooksRouterDeps } from '../../src/devplatform/routes/devWebhooks.js'; import { createTriggerJob, type CreateTriggerJobInput, diff --git a/middleware/test/devplatform/devWebhooksConcurrency.pg.test.ts b/middleware/test/devplatform/devWebhooksConcurrency.pg.test.ts index 96391f99..7aae71e2 100644 --- a/middleware/test/devplatform/devWebhooksConcurrency.pg.test.ts +++ b/middleware/test/devplatform/devWebhooksConcurrency.pg.test.ts @@ -18,7 +18,7 @@ import { hasActiveTriggerJob, } from '../../src/devplatform/triggers/triggerJobService.js'; import { WebhookDeliveryStore } from '../../src/devplatform/triggers/webhookDeliveryStore.js'; -import { createDevWebhooksRouter, type DevWebhooksRouterDeps } from '../../src/routes/devWebhooks.js'; +import { createDevWebhooksRouter, type DevWebhooksRouterDeps } from '../../src/devplatform/routes/devWebhooks.js'; import type { DevRepo } from '../../src/devplatform/types.js'; /** diff --git a/middleware/test/devplatform/goldenFixture.e2e.test.ts b/middleware/test/devplatform/goldenFixture.e2e.test.ts index 7f4b7ba0..276244a6 100644 --- a/middleware/test/devplatform/goldenFixture.e2e.test.ts +++ b/middleware/test/devplatform/goldenFixture.e2e.test.ts @@ -20,6 +20,7 @@ import { DevRepoStore } from '../../src/devplatform/devRepoStore.js'; import { DevRepoCredentialStore } from '../../src/devplatform/devRepoCredentials.js'; import { applyHunks } from '../../src/devplatform/policy/parseUnifiedDiff.js'; import { assembleDevPlatform, mountDevPlatform } from '../../src/devplatform/wireDevPlatform.js'; +import { devPlatformTestConfig } from './devPlatformConfig.harness.js'; import { InMemorySecretVault } from '../../src/secrets/vault.js'; import type { ApplyDiffInput, @@ -370,15 +371,13 @@ describe('dev-platform golden fixture (pg + git)', { skip: !pgAvailable || !gitA wired = assembleDevPlatform({ pool, vault, - baseUrl, - cliBin: fakeCli, - wallClockMs: 120_000, - heartbeatTimeoutMs: 120_000, - maxConcurrentJobs: 1, - commitAuthor: 'omadia-dev ', - subscriptionModeEnabled: false, - workspaceDir: path.join(scratch, 'jobs'), - unsafeLocal: false, + config: devPlatformTestConfig({ + baseUrl, + cliBin: fakeCli, + wallClockMs: 120_000, + heartbeatTimeoutMs: 120_000, + workspaceDir: path.join(scratch, 'jobs'), + }), shimEntry: '/dev/null', backends: [backend], forgeFactory: () => forge, diff --git a/middleware/test/devplatform/llmProxy.test.ts b/middleware/test/devplatform/llmProxy.test.ts index 5e166edf..35147757 100644 --- a/middleware/test/devplatform/llmProxy.test.ts +++ b/middleware/test/devplatform/llmProxy.test.ts @@ -14,7 +14,7 @@ import { type LlmProxyJob, type LlmProxyUsageRecord, } from '../../src/devplatform/llmProxy.js'; -import { createDevRunnerRouter, type DevRunnerRouterDeps } from '../../src/routes/devRunnerApi.js'; +import { createDevRunnerRouter, type DevRunnerRouterDeps } from '../../src/devplatform/routes/devRunnerApi.js'; import type { DevJobStatus } from '../../src/devplatform/types.js'; /** diff --git a/middleware/test/devplatform/llmProxyAccounting.pg.test.ts b/middleware/test/devplatform/llmProxyAccounting.pg.test.ts index 2df815cc..6761cd0c 100644 --- a/middleware/test/devplatform/llmProxyAccounting.pg.test.ts +++ b/middleware/test/devplatform/llmProxyAccounting.pg.test.ts @@ -24,7 +24,7 @@ import { type LlmProxyBudgetHook, type LlmProxyUsageRecord, } from '../../src/devplatform/llmProxy.js'; -import { createDevRunnerRouter, type DevRunnerRouterDeps } from '../../src/routes/devRunnerApi.js'; +import { createDevRunnerRouter, type DevRunnerRouterDeps } from '../../src/devplatform/routes/devRunnerApi.js'; /** * Epic #470 W4 (spec §5) — LLM budget accounting + enforcement, wired through the diff --git a/middleware/test/devplatform/scopedScmToken.pg.test.ts b/middleware/test/devplatform/scopedScmToken.pg.test.ts index 1584cd1e..ec371da2 100644 --- a/middleware/test/devplatform/scopedScmToken.pg.test.ts +++ b/middleware/test/devplatform/scopedScmToken.pg.test.ts @@ -9,6 +9,7 @@ import { Pool } from 'pg'; import { runMultiOrchestratorMigrations } from '@omadia/orchestrator'; import { assembleDevPlatform, mountDevPlatform } from '../../src/devplatform/wireDevPlatform.js'; +import { devPlatformTestConfig } from './devPlatformConfig.harness.js'; import { DevGithubAppStore } from '../../src/devplatform/githubApp/appStore.js'; import { DevRepoStore } from '../../src/devplatform/devRepoStore.js'; import { InMemorySecretVault } from '../../src/secrets/vault.js'; @@ -111,15 +112,10 @@ describe('dev-platform wiring — the runner gets a SCOPED, revocable App token wired = assembleDevPlatform({ pool, vault, - baseUrl: 'http://127.0.0.1:3333', - cliBin: 'claude', - wallClockMs: 600_000, - heartbeatTimeoutMs: 600_000, - maxConcurrentJobs: 1, - commitAuthor: 'omadia-dev ', - subscriptionModeEnabled: false, - workspaceDir: '/tmp/scoped-scm', - unsafeLocal: false, + config: devPlatformTestConfig({ + baseUrl: 'http://127.0.0.1:3333', + workspaceDir: '/tmp/scoped-scm', + }), shimEntry: '/dev/null', backends: [new InertBackend()], githubAppFetch, diff --git a/middleware/test/devplatform/trackerPoller.pg.test.ts b/middleware/test/devplatform/trackerPoller.pg.test.ts index 2a455719..added1cb 100644 --- a/middleware/test/devplatform/trackerPoller.pg.test.ts +++ b/middleware/test/devplatform/trackerPoller.pg.test.ts @@ -21,7 +21,7 @@ import { PgTrackerPollStore, } from '../../src/devplatform/triggers/trackerPoller.js'; import type { Ticket } from '../../src/devplatform/githubIssuesTracker.js'; -import type { DevPlatformTracker } from '../../src/routes/devPlatformShared.js'; +import type { DevPlatformTracker } from '../../src/devplatform/routes/devPlatformShared.js'; import type { DevRepo, RunnerBackendKind } from '../../src/devplatform/types.js'; /** diff --git a/middleware/test/devplatform/trackerRegistry.test.ts b/middleware/test/devplatform/trackerRegistry.test.ts index df70bb6f..6e0cb733 100644 --- a/middleware/test/devplatform/trackerRegistry.test.ts +++ b/middleware/test/devplatform/trackerRegistry.test.ts @@ -7,7 +7,7 @@ import { type PluginTrackerFactory, } from '../../src/devplatform/triggers/trackerRegistry.js'; import type { IssuesFetch } from '../../src/devplatform/githubIssuesTracker.js'; -import type { DevPlatformTracker } from '../../src/routes/devPlatformShared.js'; +import type { DevPlatformTracker } from '../../src/devplatform/routes/devPlatformShared.js'; import type { DevRepo } from '../../src/devplatform/types.js'; /** From 17f4544de324278d4a00d5db9b2c2980fdf85249 Mon Sep 17 00:00:00 2001 From: Marcel Wege Date: Fri, 31 Jul 2026 07:15:10 +0200 Subject: [PATCH 2/2] chore(470): hand-raise the ratchet baseline to 3,365 for C3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FIRST TIME the baseline rises for OUR OWN change rather than for something main added. That deserves the explanation to be in the diff, which is why the script refuses `--update` here and forces a hand-edit. Why it rose (+59): the refactor collapses 41 flat DEV_*/FLY_* config keys into one `config.devPlatform` object. The zod schema is unchanged — proven byte-identical, 562 lines, empty diff — so every key name still appears there, and the new mapping layer that builds the namespace names each one a SECOND time. Net: config.ts +48, the new devplatform/config.ts +36, against index.ts −33 and wireDevPlatform.ts −8. The eight moved routers are a wash: same zone, different path. Why it is worth taking: index.ts drops from ~20 threaded config values to one argument, and the layering arrow now points one way (zero src/devplatform → src/routes edges, zero src/routes → devplatform). Both make the P4 file-move mechanical instead of a rewrite. And every one of those +59 references is deleted at extraction — the schema keys, the mapping, and the type file all go with the tree. The honest tension: the ratchet counts identifiers, and this refactor concentrates identifiers rather than removing them. Letting that block a correct refactor would be the measurement driving the work. Documented in README so the next raise is judged against the same two criteria and nothing wider. --- specs/470-dev-platform-plugin/README.md | 9 +++++++-- specs/470-dev-platform-plugin/acceptance.md | 2 +- specs/470-dev-platform-plugin/decoupling-baseline.json | 8 ++++---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/specs/470-dev-platform-plugin/README.md b/specs/470-dev-platform-plugin/README.md index 0711413b..8fde1ac2 100644 --- a/specs/470-dev-platform-plugin/README.md +++ b/specs/470-dev-platform-plugin/README.md @@ -97,7 +97,7 @@ node scripts/check-core-decoupling.mjs --update # lower the baseline ``` The ratchet counts Dev Platform references across 14 disjoint zones and **fails if the count -rises, per zone**. Baseline **3,303**. It only ever falls; raising it needs a hand-edited baseline, so +rises, per zone**. Baseline **3,365**. It only ever falls; raising it needs a hand-edited baseline, so a new coupling shows up in review instead of slipping in. That is what makes the checklist's staleness survivable — a file inventory goes stale on @@ -106,7 +106,12 @@ contact, but the count does not, and it cannot reach zero while a reference surv But it counts IDENTIFIERS, NOT BEHAVIOUR: zero is a necessary condition for done, not a sufficient one. Sections 2 and 3 of `acceptance.md` cover the rest, and neither is automated. -**The baseline rises when main legitimately adds dev-platform code.** That has happened three +**The baseline rises for two reasons, and only two.** (1) main legitimately adds dev-platform +code. (2) A refactor concentrates coupling into a namespace whose own name matches the +pattern — C3 is the one instance: collapsing 41 flat config keys into `config.devPlatform` +added a mapping layer that names each key a second time (+48 in config.ts, +36 in the new +type file, against −33 in index.ts). All of it deletes at extraction. Everything else is a +regression. That has happened three times (PR #529, then #537's embedding work): the guard fires, the raise is hand-edited, and the reason is recorded in the commit. A rise is only wrong when *core* re-acquires a dependency. diff --git a/specs/470-dev-platform-plugin/acceptance.md b/specs/470-dev-platform-plugin/acceptance.md index 94cb6b39..83d79fbe 100644 --- a/specs/470-dev-platform-plugin/acceptance.md +++ b/specs/470-dev-platform-plugin/acceptance.md @@ -16,7 +16,7 @@ every row passes *and* the decoupling ratchet reads zero. | Guard | What it proves | Status | |---|---|---| -| `scripts/check-core-decoupling.mjs` + CI job `core decoupling ratchet (#470)` | Core does not re-acquire Dev Platform references while the extraction is in flight | **In place.** Baseline **3,303** across **14** zones, per-zone regression check | +| `scripts/check-core-decoupling.mjs` + CI job `core decoupling ratchet (#470)` | Core does not re-acquire Dev Platform references while the extraction is in flight | **In place.** Baseline **3,365** across **14** zones, per-zone regression check | | `middleware/test/devplatform/**` (54 files) | The behaviour itself, at unit/integration level. These **move with the plugin** and must stay green in the new repo | In place, moves in P4 | | §2 capability matrix below | Nothing is silently dropped in the move | **Written here; not yet automated** | | §3 install/uninstall | The result is genuinely installable | **Not yet built** — needs P3/P4 | diff --git a/specs/470-dev-platform-plugin/decoupling-baseline.json b/specs/470-dev-platform-plugin/decoupling-baseline.json index f89dec4a..86302ef9 100644 --- a/specs/470-dev-platform-plugin/decoupling-baseline.json +++ b/specs/470-dev-platform-plugin/decoupling-baseline.json @@ -1,9 +1,9 @@ { - "total": 3306, + "total": 3365, "zones": { - "middleware/src": 1636, - "middleware/test": 966, - "middleware/packages": 99, + "middleware/src": 1665, + "middleware/test": 998, + "middleware/packages": 97, "middleware/scripts": 8, "middleware/sidecars": 195, "middleware/migrations": 70,