From cb82891198d280902e50230ff0751a32b11dcd85 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:08:48 +0200 Subject: [PATCH 1/4] fix: isolate Compatibility Lab from core runtime --- src/config.ts | 3 ++ src/router.ts | 6 ++- src/server/index.ts | 52 +++++++++++------- src/server/lifecycle.ts | 16 ++++-- src/server/management-api.ts | 28 +++++++--- .../management/lab-automation-routes.ts | 9 ++++ src/server/responses/core.ts | 16 ++++-- src/types.ts | 7 +++ tests/lab-core-isolation.test.ts | 54 +++++++++++++++++++ 9 files changed, 158 insertions(+), 33 deletions(-) create mode 100644 tests/lab-core-isolation.test.ts diff --git a/src/config.ts b/src/config.ts index 703a444d8..85dcf4066 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1251,6 +1251,9 @@ const configSchema = z.object({ providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), + // Compatibility Lab integration with the ordinary server runtime is explicit + // opt-in. A bad hand edit degrades to OFF instead of invalidating providers. + labIntegrationEnabled: z.boolean().optional().catch(false), // Invalid optional recovery config must not discard unrelated provider/account state. agentTaskRecovery: agentTaskRecoverySchema.optional().catch(undefined), // These selections pre-date schema validation and used to pass through as diff --git a/src/router.ts b/src/router.ts index 12edcd532..aa36e647c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -31,7 +31,6 @@ import { } from "./routing/trace"; import { getRoutingProfile, resolvePolicyProfileId } from "./routing/profile"; import { evaluatePolicyProfile, type PolicyRequestEvidence } from "./routing/evaluator"; -import { assemblePolicyCandidateEvidence } from "./routing/compatibility/assemble"; export class NoEligiblePolicyCandidateError extends Error { /** Evaluation trace (with per-candidate exclusions) when nothing qualified. */ @@ -518,6 +517,11 @@ function routeModelInternal( const policyId = !bypassCombos ? resolvePolicyProfileId(config, modelId) : null; const profile = policyId ? getRoutingProfile(config, policyId) : undefined; if (profile && policyId) { + // Compatibility evidence is needed only for an explicit policy route. + // Keep its Lab-backed implementation out of the normal concrete route path. + const { assemblePolicyCandidateEvidence } = require( + "./routing/compatibility/assemble", + ) as typeof import("./routing/compatibility/assemble"); // One clock read per decision keeps candidate evidence, exclusions, and // scores mutually consistent and reproducible. const now = Date.now(); diff --git a/src/server/index.ts b/src/server/index.ts index 099991d27..def000db5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -45,12 +45,6 @@ import { registerDefaultAppOwnedObservedBuffers, } from "../lib/app-owned-memory-stores"; import { acquireServerBackgroundLifecycle } from "./background-lifecycle"; -import { - setLabAutomationDispatchDeps, - startLabAutomationScheduler, -} from "../lab/automation/orchestrator"; -import { loadLabAutomationPolicy } from "../lab/automation/persistence"; -import { createProductionLabRouteExecutor } from "../lib/lab-live-route-production"; import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup"; import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup"; import { runModelRenameStartupMigration } from "../providers/model-rename-startup"; @@ -81,6 +75,7 @@ import { isDraining, registerTurn, runListenerShutdown, + setLabAutomationShutdownHook, setServerRef, trackStreamLifetime, tryAdmitTurn, @@ -1735,18 +1730,39 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server config, - }); - setLabAutomationDispatchDeps({ - configDir: labConfigDir, - loadConfig: () => config, - routeExecutor: productionLabRouteExecutor, - }); - if (loadLabAutomationPolicy(labConfigDir).enabled) { - startLabAutomationScheduler(labConfigDir); + // Compatibility Lab runtime integration is explicit opt-in. Keep all Lab + // automation modules outside the ordinary server startup graph while + // preserving startServer's synchronous API. + if (config.labIntegrationEnabled === true) { + const { + requestLabAutomationShutdown, + setLabAutomationDispatchDeps, + startLabAutomationScheduler, + stopLabAutomationScheduler, + } = require("../lab/automation/orchestrator") as typeof import("../lab/automation/orchestrator"); + const { loadLabAutomationPolicy } = require( + "../lab/automation/persistence", + ) as typeof import("../lab/automation/persistence"); + const { createProductionLabRouteExecutor } = require( + "../lib/lab-live-route-production", + ) as typeof import("../lib/lab-live-route-production"); + const labConfigDir = getConfigDir(); + const productionLabRouteExecutor = createProductionLabRouteExecutor({ + configDir: labConfigDir, + loadConfig: () => config, + }); + setLabAutomationDispatchDeps({ + configDir: labConfigDir, + loadConfig: () => config, + routeExecutor: productionLabRouteExecutor, + }); + setLabAutomationShutdownHook(() => { + requestLabAutomationShutdown(); + stopLabAutomationScheduler(); + }); + if (loadLabAutomationPolicy(labConfigDir).enabled) { + startLabAutomationScheduler(labConfigDir); + } } return server; diff --git a/src/server/lifecycle.ts b/src/server/lifecycle.ts index 9afb18e25..71922cf3d 100644 --- a/src/server/lifecycle.ts +++ b/src/server/lifecycle.ts @@ -7,7 +7,6 @@ import { } from "../storage/policy-job"; import { abortRestoreTrashJobAsync } from "../storage/restore-job"; import { stopStorageCleanupScheduler } from "../storage/policy-scheduler"; -import { stopLabAutomationScheduler, requestLabAutomationShutdown } from "../lab/automation/orchestrator"; import { stopStateStoreSweeper } from "../lib/state-store-sweeper"; import { cancelQueuedStorageWorkerSpawns, @@ -52,6 +51,17 @@ let _serverRef: ReturnType | undefined; let serverStopFlights = new WeakMap, Promise>(); let serverStartupReleaseFlights = new WeakMap, Promise>(); let releaseServerStartupLifecycleImpl: typeof releaseNativeMainStartupLifecycle = releaseNativeMainStartupLifecycle; +let labAutomationShutdownHook: (() => void) | null = null; + +export function setLabAutomationShutdownHook(hook: (() => void) | null): void { + labAutomationShutdownHook = hook; +} + +function runLabAutomationShutdownHook(): void { + const hook = labAutomationShutdownHook; + labAutomationShutdownHook = null; + hook?.(); +} export function setServerRef(server: ReturnType | undefined): void { _serverRef = server; } /** @@ -156,6 +166,7 @@ export function resetLifecycleDrainStateForTests(): void { serverStopFlights = new WeakMap, Promise>(); serverStartupReleaseFlights = new WeakMap, Promise>(); releaseServerStartupLifecycleImpl = releaseNativeMainStartupLifecycle; + labAutomationShutdownHook = null; } export function tryAdmitTurn(): ActiveTurnLease | null { if (isDraining()) return null; @@ -452,8 +463,7 @@ export async function drainAndShutdown( // Abort each job independently so one wedged join cannot skip the other, // then drain leftovers; failures must not prevent `server.stop`. stopStorageCleanupScheduler(); - requestLabAutomationShutdown(); - stopLabAutomationScheduler(); + runLabAutomationShutdownHook(); stopStateStoreSweeper(); // The overlay reconciler is owner-scoped: the startServer stop override // releases THIS server's lease through runListenerShutdown → diff --git a/src/server/management-api.ts b/src/server/management-api.ts index bff89ccea..342826c1b 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -61,15 +61,12 @@ import { handleConfigRoutes } from "./management/config-routes"; import { handleLogsUsageRoutes } from "./management/logs-usage-routes"; import { handleRequestHistoryRoutes } from "./management/request-history-routes"; import { handleRoutingAnalyticsRoutes } from "./management/routing-analytics-routes"; -import { handleRoutingProfileRoutes } from "./management/routing-profile-routes"; import { handleProviderRoutes } from "./management/provider-routes"; import { handleModelRoutes } from "./management/model-routes"; import { handleAgentSettingsRoutes } from "./management/agent-settings-routes"; import { handleOauthAccountRoutes } from "./management/oauth-account-routes"; import { handleComboRoutes } from "./management/combo-routes"; import { handleSystemRoutes } from "./management/system-routes"; -import { handleLabRoutes } from "./management/lab-routes"; -import { handleLabAutomationRoutes } from "./management/lab-automation-routes"; import { handleSidebarRoutes } from "./management/sidebar-routes"; import { handleIntegrationRoutes } from "./management/integration-routes"; import { handleNativeIntegrationRoutes } from "./management/native-integration-routes"; @@ -91,6 +88,26 @@ export const VERSION = (() => { } })(); +function pathInManagementNamespace(pathname: string, prefix: string): boolean { + return pathname === prefix || pathname.startsWith(`${prefix}/`); +} + +async function handleRoutingProfileRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/routing-profiles")) return null; + const { handleRoutingProfileRoutes } = await import("./management/routing-profile-routes"); + return handleRoutingProfileRoutes(ctx); +} + +async function handleLabRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/lab")) return null; + if (pathInManagementNamespace(ctx.url.pathname, "/api/lab/automation")) { + const { handleLabAutomationRoutes } = await import("./management/lab-automation-routes"); + return handleLabAutomationRoutes(ctx); + } + const { handleLabRoutes } = await import("./management/lab-routes"); + return handleLabRoutes(ctx); +} + const managementConvergenceBindings = new WeakMap) => ConvergeCodex; converge: ConvergeCodex; @@ -180,7 +197,7 @@ export async function handleManagementAPI( ?? (await handleLogsUsageRoutes(ctx)) ?? (await handleRequestHistoryRoutes(ctx)) ?? (await handleRoutingAnalyticsRoutes(ctx)) - ?? (await handleRoutingProfileRoutes(ctx)) + ?? (await handleRoutingProfileRoutesOnDemand(ctx)) ?? (await handleProviderRoutes(ctx)) ?? (await handleModelRoutes(ctx)) ?? (await handleIntegrationRoutes(ctx)) @@ -189,8 +206,7 @@ export async function handleManagementAPI( ?? (await handleOauthAccountRoutes(ctx)) ?? (await handleComboRoutes(ctx)) ?? (await handleSystemRoutes(ctx)) - ?? (await handleLabAutomationRoutes(ctx)) - ?? (await handleLabRoutes(ctx)) + ?? (await handleLabRoutesOnDemand(ctx)) ?? (await handleSidebarRoutes(ctx)); } catch (error) { const tooLarge = managementBodyTooLargeResponse(error, req, config); diff --git a/src/server/management/lab-automation-routes.ts b/src/server/management/lab-automation-routes.ts index dfaf0f4e9..a2b6061ac 100644 --- a/src/server/management/lab-automation-routes.ts +++ b/src/server/management/lab-automation-routes.ts @@ -16,6 +16,7 @@ import { cancelLabAutomationRun, enqueueManualLabRun, reconcileLabAutomationQueue, + requestLabAutomationShutdown, startLabAutomationScheduler, stopLabAutomationScheduler, } from "../../lab/automation/orchestrator"; @@ -33,6 +34,7 @@ import { listLabAutomationRuns } from "../../lab/automation/runs-query"; import type { LabAutomationLayer, LabAutomationPolicyV1 } from "../../lab/automation/types"; import { LabAutomationError } from "../../lab/automation/types"; import { jsonResponse } from "../auth-cors"; +import { setLabAutomationShutdownHook } from "../lifecycle"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; import { isPlainRecord } from "./shared"; @@ -76,6 +78,13 @@ export async function handleLabAutomationRoutes(ctx: ManagementContext): Promise const { url, req, config } = ctx; if (!url.pathname.startsWith("/api/lab/automation")) return null; + // This module is loaded only after an explicit Lab automation request. Once + // loaded, register cleanup without making lifecycle.ts import Lab code. + setLabAutomationShutdownHook(() => { + requestLabAutomationShutdown(); + stopLabAutomationScheduler(); + }); + const configDir = getConfigDir(); if (url.pathname === "/api/lab/automation" && req.method === "GET") { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index ca928e21c..3e23e6104 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -33,7 +33,6 @@ import { type RouteResult, } from "../../router"; import { evidenceFromBody } from "../../routing/request-evidence"; -import { resolveProductionRouteSubject } from "../../routing/compatibility/subject"; import { advanceComboAfterFailure, comboDefaultEffort, @@ -1991,11 +1990,18 @@ async function handleResponsesInner( (logCtx.attempts ??= []).push(attempt); } sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name, logCtx.accountLogLabel); - // CL-09: attach only the opaque exact route-subject identity to the attempt. - // This is best-effort passive metadata: no Lab state is created and failure - // must never alter, retry, or delay the upstream request. - if (logCtx.activeAttempt && !logCtx.activeAttempt.labRouteSubjectId) { + // CL-09 passive linkage is part of the explicit Lab integration only. Keep + // both the module load and subject construction out of the ordinary request + // path when Lab integration is disabled. + if ( + config.labIntegrationEnabled === true + && logCtx.activeAttempt + && !logCtx.activeAttempt.labRouteSubjectId + ) { try { + const { resolveProductionRouteSubject } = require( + "../../routing/compatibility/subject", + ) as typeof import("../../routing/compatibility/subject"); const passiveSubject = resolveProductionRouteSubject( config, route.providerName, diff --git a/src/types.ts b/src/types.ts index 6d0e84aff..a54bad32c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -925,6 +925,13 @@ export interface OcxConfig { }; /** Virtual `combo/` models spanning concrete provider/model targets (issue #133). */ combos?: Record; + /** + * Automatic Compatibility Lab integration with the ordinary server runtime. + * Default false. When disabled/unset, normal requests and server startup do + * not load Lab modules. Explicit Lab commands/APIs and explicit policy routes + * may still load their Lab dependencies on demand. + */ + labIntegrationEnabled?: boolean; /** * Routing policy profiles (Router Intelligence, RI-04+): explicitly requested * `policy/` (or configured alias) models select among an explicit diff --git a/tests/lab-core-isolation.test.ts b/tests/lab-core-isolation.test.ts new file mode 100644 index 000000000..a24ffe5f3 --- /dev/null +++ b/tests/lab-core-isolation.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "bun:test"; + +describe("Compatibility Lab core isolation", () => { + test("ordinary responses do not statically load or run Lab subject code", () => { + const source = readFileSync("src/server/responses/core.ts", "utf8"); + expect(source).not.toContain( + 'import { resolveProductionRouteSubject } from "../../routing/compatibility/subject"', + ); + expect(source).toContain("config.labIntegrationEnabled === true"); + expect(source).toContain('require(\n "../../routing/compatibility/subject",'); + }); + + test("concrete routing does not statically load compatibility evidence", () => { + const source = readFileSync("src/router.ts", "utf8"); + expect(source).not.toContain( + 'import { assemblePolicyCandidateEvidence } from "./routing/compatibility/assemble"', + ); + const profileBranch = source.indexOf("if (profile && policyId)"); + const lazyLoad = source.indexOf('require(\n "./routing/compatibility/assemble",'); + expect(profileBranch).toBeGreaterThanOrEqual(0); + expect(lazyLoad).toBeGreaterThan(profileBranch); + }); + + test("ordinary server startup and shutdown have no static Lab automation dependency", () => { + const indexSource = readFileSync("src/server/index.ts", "utf8"); + const lifecycleSource = readFileSync("src/server/lifecycle.ts", "utf8"); + + expect(indexSource).not.toContain('from "../lab/automation/orchestrator"'); + expect(indexSource).not.toContain('from "../lab/automation/persistence"'); + expect(indexSource).not.toContain('from "../lib/lab-live-route-production"'); + expect(indexSource).toContain("config.labIntegrationEnabled === true"); + expect(indexSource).toContain('require("../lab/automation/orchestrator")'); + + expect(lifecycleSource).not.toContain("../lab/automation/orchestrator"); + expect(lifecycleSource).toContain("runLabAutomationShutdownHook()"); + }); + + test("normal management traffic does not load Lab or routing-profile compatibility routes", () => { + const source = readFileSync("src/server/management-api.ts", "utf8"); + + expect(source).not.toContain('from "./management/lab-routes"'); + expect(source).not.toContain('from "./management/lab-automation-routes"'); + expect(source).not.toContain('from "./management/routing-profile-routes"'); + expect(source).toContain('import("./management/lab-routes")'); + expect(source).toContain('import("./management/lab-automation-routes")'); + expect(source).toContain('import("./management/routing-profile-routes")'); + }); + + test("Lab integration flag is explicit opt-in in config parsing", () => { + const source = readFileSync("src/config.ts", "utf8"); + expect(source).toContain("labIntegrationEnabled: z.boolean().optional().catch(false)"); + }); +}); From 4309d8ae719ffeb4a7e720fa81563f1263a82237 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:41:36 +0200 Subject: [PATCH 2/4] fix: preserve explicit Lab automation behavior --- src/cli/lab.ts | 23 ++- src/server/lab-automation-runtime.ts | 68 +++++++++ .../management/lab-automation-routes.ts | 41 ++++-- tests/lab-core-isolation.test.ts | 86 +++++------ ...ab-runtime-integration-regressions.test.ts | 136 ++++++++++++++++++ 5 files changed, 297 insertions(+), 57 deletions(-) create mode 100644 src/server/lab-automation-runtime.ts create mode 100644 tests/lab-runtime-integration-regressions.test.ts diff --git a/src/cli/lab.ts b/src/cli/lab.ts index 013b96327..415895e8a 100644 --- a/src/cli/lab.ts +++ b/src/cli/lab.ts @@ -4,7 +4,8 @@ * Read commands use the local SQLite projection. Automation mutations and manual runs are * explicit operator actions; read commands never start probes or scheduler ticks. */ -import { getConfigDir, readConfigDiagnostics } from "../config"; +import { getConfigDir, readConfigDiagnostics, saveConfigPreservingClaudeCode } from "../config"; +import type { OcxConfig } from "../types"; import { ARTIFACT_CLASSES, EVIDENCE_LAYERS, @@ -87,6 +88,8 @@ type ArtifactStatus = (typeof ARTIFACT_STATUSES)[number]; export interface LabCliDeps { configDir?: string; + loadConfig?: () => OcxConfig; + saveConfig?: (config: OcxConfig) => void; } class LabStateError extends RuntimeApiError { @@ -104,6 +107,19 @@ function labErrorMessage(err: unknown): string { return "lab read failed"; } +function loadCliConfig(deps: LabCliDeps): OcxConfig { + return deps.loadConfig?.() ?? readConfigDiagnostics().config; +} + +function persistLabIntegrationOptIn(deps: LabCliDeps): void { + const config = loadCliConfig(deps); + if (config.labIntegrationEnabled === true) return; + (deps.saveConfig ?? saveConfigPreservingClaudeCode)({ + ...config, + labIntegrationEnabled: true, + }); +} + function takeEnumOption( args: string[], flag: string, @@ -394,6 +410,9 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P }, taskEffectivenessBackgroundEnabled: false, }; + // `automation enable` is the explicit operator opt-in. Persist the + // core runtime gate too so server restart cannot silently disable it. + persistLabIntegrationOptIn(deps); saveLabAutomationPolicyConfig(policy, configDir); reconcileLabAutomationQueue(configDir); startLabAutomationScheduler(configDir); @@ -435,7 +454,7 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P const modelId = takeOption(rest, "--model"); rejectArgs(rest, USAGE); if (!layer || !scenarioId) throw new CliUsageError("--layer and --scenario are required", USAGE); - const configSnapshot = readConfigDiagnostics().config; + const configSnapshot = loadCliConfig(deps); if (layer === "live_route_compatibility") { const loadConfig = () => configSnapshot; setLabAutomationDispatchDeps({ diff --git a/src/server/lab-automation-runtime.ts b/src/server/lab-automation-runtime.ts new file mode 100644 index 000000000..bbb08b213 --- /dev/null +++ b/src/server/lab-automation-runtime.ts @@ -0,0 +1,68 @@ +import { readConfigDiagnostics } from "../config"; +import type { OcxConfig } from "../types"; +import { setLabAutomationShutdownHook } from "./lifecycle"; + +type RuntimeOwner = { + release: () => void; +}; + +const runtimeOwners = new Map(); + +function configKey(configDir?: string): string { + return configDir ?? ""; +} + +function loadLatestConfig(fallback: OcxConfig): OcxConfig { + try { + return readConfigDiagnostics().config; + } catch { + return fallback; + } +} + +function installShutdownHook(): void { + setLabAutomationShutdownHook(() => { + const { + requestLabAutomationShutdown, + stopLabAutomationScheduler, + } = require("../lab/automation/orchestrator") as typeof import("../lab/automation/orchestrator"); + requestLabAutomationShutdown(); + for (const [key, owner] of runtimeOwners) { + stopLabAutomationScheduler(key || undefined); + owner.release(); + } + runtimeOwners.clear(); + }); +} + +/** + * Lazily install the host-owned dispatch authority required by explicit Lab + * automation requests. Importing this module alone does not load Lab runtime + * code; the Lab graph is entered only when an explicit caller invokes this. + */ +export function ensureLabAutomationRuntime(config: OcxConfig, configDir?: string): void { + const key = configKey(configDir); + if (runtimeOwners.has(key)) return; + + const { setLabAutomationDispatchDeps } = require( + "../lab/automation/orchestrator", + ) as typeof import("../lab/automation/orchestrator"); + const { createProductionLabRouteExecutor } = require( + "../lib/lab-live-route-production", + ) as typeof import("../lib/lab-live-route-production"); + const loadConfig = () => loadLatestConfig(config); + const release = setLabAutomationDispatchDeps({ + configDir, + loadConfig, + routeExecutor: createProductionLabRouteExecutor({ configDir, loadConfig }), + }); + runtimeOwners.set(key, { release }); + installShutdownHook(); +} + +/** Test-only reset for direct management-route tests that do not own a server lifecycle. */ +export function resetLabAutomationRuntimeForTests(): void { + for (const owner of runtimeOwners.values()) owner.release(); + runtimeOwners.clear(); + setLabAutomationShutdownHook(null); +} diff --git a/src/server/management/lab-automation-routes.ts b/src/server/management/lab-automation-routes.ts index a2b6061ac..1a1a5fa81 100644 --- a/src/server/management/lab-automation-routes.ts +++ b/src/server/management/lab-automation-routes.ts @@ -10,13 +10,12 @@ * Read endpoints never trigger scheduler ticks or evidence collection. */ -import { readConfigDiagnostics, getConfigDir } from "../../config"; +import { readConfigDiagnostics, getConfigDir, saveConfigPreservingClaudeCode } from "../../config"; import { buildLabAutomationStatus, cancelLabAutomationRun, enqueueManualLabRun, reconcileLabAutomationQueue, - requestLabAutomationShutdown, startLabAutomationScheduler, stopLabAutomationScheduler, } from "../../lab/automation/orchestrator"; @@ -34,7 +33,7 @@ import { listLabAutomationRuns } from "../../lab/automation/runs-query"; import type { LabAutomationLayer, LabAutomationPolicyV1 } from "../../lab/automation/types"; import { LabAutomationError } from "../../lab/automation/types"; import { jsonResponse } from "../auth-cors"; -import { setLabAutomationShutdownHook } from "../lifecycle"; +import { ensureLabAutomationRuntime } from "../lab-automation-runtime"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; import { isPlainRecord } from "./shared"; @@ -65,7 +64,23 @@ function parseLimit(raw: string | null, ctx: ManagementContext): number | Respon return value; } -function applySchedulerPolicy(policy: LabAutomationPolicyV1, configDir?: string): void { +function persistLabIntegrationOptIn(ctx: ManagementContext): void { + if (ctx.config.labIntegrationEnabled === true) return; + const persistConfig = ctx.deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode; + let current = ctx.config; + if (!ctx.deps.saveConfigPreservingClaudeCode) { + try { + current = readConfigDiagnostics().config; + } catch { + // The server already has a validated runtime config; use it as the safe fallback. + } + } + if (current.labIntegrationEnabled === true) return; + persistConfig({ ...current, labIntegrationEnabled: true }); +} + +function applySchedulerPolicy(policy: LabAutomationPolicyV1, config: import("../../types").OcxConfig, configDir?: string): void { + if (policy.enabled) ensureLabAutomationRuntime(config, configDir); reconcileLabAutomationQueue(configDir); if (policy.enabled) { startLabAutomationScheduler(configDir); @@ -78,13 +93,6 @@ export async function handleLabAutomationRoutes(ctx: ManagementContext): Promise const { url, req, config } = ctx; if (!url.pathname.startsWith("/api/lab/automation")) return null; - // This module is loaded only after an explicit Lab automation request. Once - // loaded, register cleanup without making lifecycle.ts import Lab code. - setLabAutomationShutdownHook(() => { - requestLabAutomationShutdown(); - stopLabAutomationScheduler(); - }); - const configDir = getConfigDir(); if (url.pathname === "/api/lab/automation" && req.method === "GET") { @@ -146,6 +154,9 @@ export async function handleLabAutomationRoutes(ctx: ManagementContext): Promise } catch { ocxConfig = config; } + if (evidenceLayer === "live_route_compatibility") { + ensureLabAutomationRuntime(ocxConfig, configDir); + } const planned = planManualLabRun({ evidenceLayer: evidenceLayer as LabAutomationLayer, scenarioId, @@ -195,12 +206,16 @@ export async function handleLabAutomationRoutes(ctx: ManagementContext): Promise routes = normalizeLabAutomationRoutesV1(body.routes); } + // Runtime integration is a separate core-config gate. Enabling automation + // through this explicit operator surface must survive the next server restart. + if (policy.enabled) persistLabIntegrationOptIn(ctx); + // One atomic rename publishes policy and routes as a coherent generation. A failed write // leaves the previous generation authoritative and scheduler reconciliation is not applied. if (body.policy !== undefined || body.routes !== undefined) { saveLabAutomationConfig(policy, routes, configDir); } - applySchedulerPolicy(policy, configDir); + applySchedulerPolicy(policy, config, configDir); return jsonResponse(buildLabAutomationStatus(configDir), 200, req, config); } catch (error) { rethrowManagementBodyTooLarge(error); @@ -212,4 +227,4 @@ export async function handleLabAutomationRoutes(ctx: ManagementContext): Promise } return automationErrorResponse("not_found", "unknown resource", 404, ctx); -} \ No newline at end of file +} diff --git a/tests/lab-core-isolation.test.ts b/tests/lab-core-isolation.test.ts index a24ffe5f3..bdc61c141 100644 --- a/tests/lab-core-isolation.test.ts +++ b/tests/lab-core-isolation.test.ts @@ -1,54 +1,56 @@ -import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { describe, expect, test } from "bun:test"; -describe("Compatibility Lab core isolation", () => { - test("ordinary responses do not statically load or run Lab subject code", () => { - const source = readFileSync("src/server/responses/core.ts", "utf8"); - expect(source).not.toContain( - 'import { resolveProductionRouteSubject } from "../../routing/compatibility/subject"', - ); - expect(source).toContain("config.labIntegrationEnabled === true"); - expect(source).toContain('require(\n "../../routing/compatibility/subject",'); +const FORBIDDEN_RUNTIME_MODULES = [ + "/src/lab/", + "/src/lib/lab-live-route-production.ts", + "/src/routing/compatibility/assemble.ts", + "/src/server/management/lab-routes.ts", + "/src/server/management/lab-automation-routes.ts", + "/src/server/management/routing-profile-routes.ts", +] as const; + +function loadedModules(entry: string): string[] { + const script = ` + require(${JSON.stringify(`./${entry}`)}); + const loaded = Object.keys(require.cache).map((value) => value.replaceAll("\\\\", "/")); + process.stdout.write("__OCX_MODULES__" + JSON.stringify(loaded)); + `; + const result = spawnSync(process.execPath, ["-e", script], { + cwd: process.cwd(), + encoding: "utf8", + env: process.env, }); + expect(result.status, result.stderr).toBe(0); + const marker = "__OCX_MODULES__"; + const markerIndex = result.stdout.lastIndexOf(marker); + expect(markerIndex).toBeGreaterThanOrEqual(0); + return JSON.parse(result.stdout.slice(markerIndex + marker.length)) as string[]; +} + +function expectCoreEntryIsolated(entry: string): void { + const loaded = loadedModules(entry); + const forbidden = loaded.filter((modulePath) => + FORBIDDEN_RUNTIME_MODULES.some((fragment) => modulePath.includes(fragment)) + ); + expect(forbidden).toEqual([]); +} - test("concrete routing does not statically load compatibility evidence", () => { - const source = readFileSync("src/router.ts", "utf8"); - expect(source).not.toContain( - 'import { assemblePolicyCandidateEvidence } from "./routing/compatibility/assemble"', - ); - const profileBranch = source.indexOf("if (profile && policyId)"); - const lazyLoad = source.indexOf('require(\n "./routing/compatibility/assemble",'); - expect(profileBranch).toBeGreaterThanOrEqual(0); - expect(lazyLoad).toBeGreaterThan(profileBranch); +describe("Compatibility Lab core isolation", () => { + test("ordinary Responses import graph does not load Lab runtime modules", () => { + expectCoreEntryIsolated("src/server/responses/core.ts"); }); - test("ordinary server startup and shutdown have no static Lab automation dependency", () => { - const indexSource = readFileSync("src/server/index.ts", "utf8"); - const lifecycleSource = readFileSync("src/server/lifecycle.ts", "utf8"); - - expect(indexSource).not.toContain('from "../lab/automation/orchestrator"'); - expect(indexSource).not.toContain('from "../lab/automation/persistence"'); - expect(indexSource).not.toContain('from "../lib/lab-live-route-production"'); - expect(indexSource).toContain("config.labIntegrationEnabled === true"); - expect(indexSource).toContain('require("../lab/automation/orchestrator")'); - - expect(lifecycleSource).not.toContain("../lab/automation/orchestrator"); - expect(lifecycleSource).toContain("runLabAutomationShutdownHook()"); + test("router import graph does not load compatibility assembly", () => { + expectCoreEntryIsolated("src/router.ts"); }); - test("normal management traffic does not load Lab or routing-profile compatibility routes", () => { - const source = readFileSync("src/server/management-api.ts", "utf8"); - - expect(source).not.toContain('from "./management/lab-routes"'); - expect(source).not.toContain('from "./management/lab-automation-routes"'); - expect(source).not.toContain('from "./management/routing-profile-routes"'); - expect(source).toContain('import("./management/lab-routes")'); - expect(source).toContain('import("./management/lab-automation-routes")'); - expect(source).toContain('import("./management/routing-profile-routes")'); + test("server startup and lifecycle import graphs do not load Lab runtime modules", () => { + expectCoreEntryIsolated("src/server/index.ts"); + expectCoreEntryIsolated("src/server/lifecycle.ts"); }); - test("Lab integration flag is explicit opt-in in config parsing", () => { - const source = readFileSync("src/config.ts", "utf8"); - expect(source).toContain("labIntegrationEnabled: z.boolean().optional().catch(false)"); + test("management API import graph keeps Lab and routing-profile handlers on demand", () => { + expectCoreEntryIsolated("src/server/management-api.ts"); }); }); diff --git a/tests/lab-runtime-integration-regressions.test.ts b/tests/lab-runtime-integration-regressions.test.ts new file mode 100644 index 000000000..b799199ee --- /dev/null +++ b/tests/lab-runtime-integration-regressions.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import type { OcxConfig } from "../src/types"; +import { readConfigDiagnostics, saveConfigPreservingClaudeCode } from "../src/config"; +import { handleLabCommand } from "../src/cli/lab"; +import { + requestLabAutomationShutdown, + resetLabAutomationSchedulerStateForTests, + stopLabAutomationScheduler, +} from "../src/lab/automation/orchestrator"; +import { loadLabAutomationConfig } from "../src/lab/automation/config-persistence"; +import { readInstallationSalt } from "../src/lab/subject/installation-salt"; +import { + resetCompatibilityVersionCacheForTests, + setCompatibilityVersionOverrideForTests, +} from "../src/routing/compatibility/version"; +import { handleManagementAPI } from "../src/server/management-api"; +import { resetLabAutomationRuntimeForTests } from "../src/server/lab-automation-runtime"; +import { ManagementRequest } from "./helpers/management-auth"; + +const HOMES: string[] = []; +const COMPAT_VERSION = "e".repeat(64); + +function tempHome(): string { + const dir = join(tmpdir(), `ocx-lab-runtime-${process.pid}-${Math.random().toString(16).slice(2)}`); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + HOMES.push(dir); + process.env.OPENCODEX_HOME = dir; + return dir; +} + +function emptyConfig(): OcxConfig { + return { providers: {}, labIntegrationEnabled: false } as OcxConfig; +} + +function persistedLiveConfig(home: string): OcxConfig { + readInstallationSalt(home); + setCompatibilityVersionOverrideForTests(COMPAT_VERSION); + const base = readConfigDiagnostics().config; + const config = { + ...base, + defaultProvider: "fixture-provider", + labIntegrationEnabled: false, + providers: { + ...base.providers, + "fixture-provider": { + adapter: "openai-responses", + baseUrl: "https://example.com/v1", + models: ["fixture-model"], + defaultModel: "fixture-model", + }, + }, + } as OcxConfig; + saveConfigPreservingClaudeCode(config); + return config; +} + +afterEach(() => { + resetLabAutomationRuntimeForTests(); + requestLabAutomationShutdown(); + stopLabAutomationScheduler(); + resetLabAutomationSchedulerStateForTests(); + resetCompatibilityVersionCacheForTests(); + delete process.env.OPENCODEX_HOME; + for (const dir of HOMES.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe("Compatibility Lab runtime integration regressions", () => { + test("explicit live management run lazily installs production dispatch while integration is off", async () => { + const home = tempHome(); + const config = persistedLiveConfig(home); + const req = new ManagementRequest("http://127.0.0.1/api/lab/automation/run", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + evidenceLayer: "live_route_compatibility", + scenarioId: "responses-core.live.basic-turn", + providerName: "fixture-provider", + modelId: "fixture-model", + }), + }); + const res = await handleManagementAPI(req, new URL(req.url), config); + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + const body = await res!.json(); + expect(body.run.state).not.toBe("queued"); + expect(body.run.state).not.toBe("running"); + expect(body.run.terminalCode).not.toBe("route_ineligible"); + }); + + test("management automation enable persists the server runtime gate", async () => { + tempHome(); + const config = emptyConfig(); + let savedConfig: OcxConfig | undefined; + const req = new ManagementRequest("http://127.0.0.1/api/lab/automation", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + policy: { + enabled: true, + layers: { + protocolConformance: true, + liveRouteCompatibility: false, + taskEffectiveness: false, + }, + }, + }), + }); + const res = await handleManagementAPI(req, new URL(req.url), config, { + saveConfigPreservingClaudeCode: (next) => { + savedConfig = next; + }, + }); + expect(res).not.toBeNull(); + expect(res!.status).toBe(200); + expect(savedConfig?.labIntegrationEnabled).toBe(true); + expect(loadLabAutomationConfig().policy.enabled).toBe(true); + }); + + test("CLI automation enable persists the server runtime gate", async () => { + const home = tempHome(); + const config = emptyConfig(); + let savedConfig: OcxConfig | undefined; + expect(await handleLabCommand(["automation", "enable", "--protocol", "--json"], { + configDir: home, + loadConfig: () => config, + saveConfig: (next) => { + savedConfig = next; + }, + })).toBe(0); + expect(savedConfig?.labIntegrationEnabled).toBe(true); + expect(loadLabAutomationConfig(home).policy.enabled).toBe(true); + }); +}); From 85c3817a642bbfcce8dca91cf9a536fbca311175 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:49:23 +0200 Subject: [PATCH 3/4] fix: initialize Lab runtime for CLI automation --- src/cli/lab.ts | 2 ++ ...ab-runtime-integration-regressions.test.ts | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/cli/lab.ts b/src/cli/lab.ts index 415895e8a..59d1832e3 100644 --- a/src/cli/lab.ts +++ b/src/cli/lab.ts @@ -64,6 +64,7 @@ import { planManualLabRun } from "../lab/automation/planner"; import { listLabAutomationRuns } from "../lab/automation/runs-query"; import { LabAutomationError, type LabAutomationLayer } from "../lab/automation/types"; import { createProductionLabRouteExecutor } from "../lib/lab-live-route-production"; +import { ensureLabAutomationRuntime } from "../server/lab-automation-runtime"; const USAGE = `Usage: ocx lab status [--json] @@ -415,6 +416,7 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P persistLabIntegrationOptIn(deps); saveLabAutomationPolicyConfig(policy, configDir); reconcileLabAutomationQueue(configDir); + ensureLabAutomationRuntime(loadCliConfig(deps), configDir); startLabAutomationScheduler(configDir); const status = buildLabAutomationStatus(configDir); printData(automationCliStatus(status), wantsJson, automationStatusLines(status)); diff --git a/tests/lab-runtime-integration-regressions.test.ts b/tests/lab-runtime-integration-regressions.test.ts index b799199ee..679589a01 100644 --- a/tests/lab-runtime-integration-regressions.test.ts +++ b/tests/lab-runtime-integration-regressions.test.ts @@ -6,11 +6,13 @@ import type { OcxConfig } from "../src/types"; import { readConfigDiagnostics, saveConfigPreservingClaudeCode } from "../src/config"; import { handleLabCommand } from "../src/cli/lab"; import { + enqueueManualLabRun, requestLabAutomationShutdown, resetLabAutomationSchedulerStateForTests, stopLabAutomationScheduler, } from "../src/lab/automation/orchestrator"; import { loadLabAutomationConfig } from "../src/lab/automation/config-persistence"; +import { planManualLabRun } from "../src/lab/automation/planner"; import { readInstallationSalt } from "../src/lab/subject/installation-salt"; import { resetCompatibilityVersionCacheForTests, @@ -133,4 +135,27 @@ describe("Compatibility Lab runtime integration regressions", () => { expect(savedConfig?.labIntegrationEnabled).toBe(true); expect(loadLabAutomationConfig(home).policy.enabled).toBe(true); }); + + test("CLI live automation enable installs production dispatch dependencies", async () => { + const home = tempHome(); + persistedLiveConfig(home); + expect(await handleLabCommand(["automation", "enable", "--live", "--json"], { + configDir: home, + })).toBe(0); + + const config = readConfigDiagnostics().config; + const planned = planManualLabRun({ + evidenceLayer: "live_route_compatibility", + scenarioId: "responses-core.live.basic-turn", + providerName: "fixture-provider", + modelId: "fixture-model", + config, + configDir: home, + }); + const record = await enqueueManualLabRun(planned, home); + expect(record).not.toBeNull(); + expect(record?.state).not.toBe("queued"); + expect(record?.state).not.toBe("running"); + expect(record?.terminalCode).not.toBe("route_ineligible"); + }); }); From d16a184ad9ce5eb61ce5e3c4ef35b6bd4705a0aa Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:04:54 +0200 Subject: [PATCH 4/4] test: verify CLI Lab opt-in persistence --- tests/lab-runtime-integration-regressions.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/lab-runtime-integration-regressions.test.ts b/tests/lab-runtime-integration-regressions.test.ts index 679589a01..f0208a28f 100644 --- a/tests/lab-runtime-integration-regressions.test.ts +++ b/tests/lab-runtime-integration-regressions.test.ts @@ -144,6 +144,7 @@ describe("Compatibility Lab runtime integration regressions", () => { })).toBe(0); const config = readConfigDiagnostics().config; + expect(config.labIntegrationEnabled).toBe(true); const planned = planManualLabRun({ evidenceLayer: "live_route_compatibility", scenarioId: "responses-core.live.basic-turn",