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'; /** 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,