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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion middleware/src/auth/publicPaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
146 changes: 140 additions & 6 deletions middleware/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -599,7 +603,132 @@ const ConfigSchema = z.object({
DEV_FLY_REGION: optionalNonEmpty(z.string().min(1)),
});

export type Config = z.infer<typeof ConfigSchema>;
/**
* 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<typeof ConfigSchema>;

/** Every schema key the dev platform owns: prefix-matched, minus the exceptions. */
type DevPlatformEnvKey = Exclude<
Extract<keyof ParsedConfig, `DEV_${string}` | `FLY_${string}`>,
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<ParsedConfig, DevPlatformEnvKey> & {
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).
Expand Down Expand Up @@ -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<string, unknown> = { ...parsed.data };
for (const key of Object.keys(core)) {
if (isDevPlatformEnvKey(key)) delete core[key];
}

return {
...parsed.data,
...(core as Omit<ParsedConfig, DevPlatformEnvKey>),
MEMORY_SEED_DIR: resolvePath(parsed.data.MEMORY_SEED_DIR),
SKILLS_DIR: resolvePath(parsed.data.SKILLS_DIR),
UPLOADED_PACKAGES_DIR: resolveStateDir(
Expand All @@ -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),
};
}

Expand Down
141 changes: 141 additions & 0 deletions middleware/src/devplatform/config.ts
Original file line number Diff line number Diff line change
@@ -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 <email>`. */
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
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,
manifestActionUrl,
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading