From 7fe3a3426dc4b44393d14c8779008c7e1b1ec2ec Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 9 Sep 2026 20:00:37 -0700 Subject: [PATCH 1/8] feat(onboard): add external component lifecycle Signed-off-by: Apurv Kumaria --- src/lib/onboard.ts | 6 +- .../docker-driver-gateway-config-toml.test.ts | 99 ++++ .../onboard/docker-driver-gateway-config.ts | 143 ++++- src/lib/onboard/docker-driver-gateway-env.ts | 13 +- .../docker-driver-gateway-runtime.test.ts | 42 +- .../onboard/docker-driver-gateway-runtime.ts | 10 +- .../external-component/activation.test.ts | 250 +++++++++ .../onboard/external-component/activation.ts | 233 ++++++++ .../onboard/external-component/index.test.ts | 359 ++++++++++++ src/lib/onboard/external-component/index.ts | 509 ++++++++++++++++++ .../external-component/onboarding.test.ts | 24 + .../onboard/external-component/onboarding.ts | 92 ++++ .../onboard/external-component/proof.test.ts | 149 +++++ src/lib/onboard/external-component/proof.ts | 136 +++++ src/lib/onboard/gateway-reuse.ts | 15 +- .../onboard/gateway/docker-driver-start.ts | 10 +- src/lib/onboard/gateway/start.ts | 28 +- src/lib/onboard/machine/core-flow-phases.ts | 2 + src/lib/onboard/machine/final-flow-phases.ts | 2 + src/lib/onboard/machine/flow-context.ts | 3 + .../machine/handlers/finalization.test.ts | 129 +++++ .../onboard/machine/handlers/finalization.ts | 60 ++- .../onboard/machine/handlers/gateway.test.ts | 137 ++++- src/lib/onboard/machine/handlers/gateway.ts | 59 +- .../sandbox-external-component.test.ts | 30 ++ src/lib/onboard/machine/handlers/sandbox.ts | 10 + .../machine/initial-flow-composition.ts | 6 +- .../onboard/machine/initial-flow-phases.ts | 12 +- ...onboard-session-external-component.test.ts | 82 +++ src/lib/state/onboard-session.ts | 91 +++- 30 files changed, 2689 insertions(+), 52 deletions(-) create mode 100644 src/lib/onboard/external-component/activation.test.ts create mode 100644 src/lib/onboard/external-component/activation.ts create mode 100644 src/lib/onboard/external-component/index.test.ts create mode 100644 src/lib/onboard/external-component/index.ts create mode 100644 src/lib/onboard/external-component/onboarding.test.ts create mode 100644 src/lib/onboard/external-component/onboarding.ts create mode 100644 src/lib/onboard/external-component/proof.test.ts create mode 100644 src/lib/onboard/external-component/proof.ts create mode 100644 src/lib/onboard/machine/handlers/sandbox-external-component.test.ts create mode 100644 src/lib/state/onboard-session-external-component.test.ts diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 2fe87adbc3e..26417a2f531 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -452,6 +452,7 @@ const { applyHealthyPortReuse, createInitialOnboardFlowPhases, destroyGatewayForReuse, + externalComponent: component, runInitialOnboardFlowSlice, verifyGatewayContainerRunning, }: typeof import("./onboard/machine/initial-flow-composition") = require("./onboard/machine/initial-flow-composition"); @@ -2928,8 +2929,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { }, getInitialGatewayReuseState: () => selectNamedGatewayForReuseIfNeeded(getGatewayReuseSnapshot()).gatewayReuseState, - assertGatewayReadiness: () => - onboardPreflightGatewayAuthority.collectGatewayReadiness().then(() => undefined), + ...component.initialFlowDeps(onboardPreflightGatewayAuthority), gatewayName: GATEWAY_NAME, recreateSandbox: isRecreateSandbox, requiresBindMounts: effectiveHostMounts.length > 0, @@ -3286,7 +3286,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { webSearchProvider: (config) => webSearchProviderForConfig(config), }, finalizationDeps: { - setDefaultSandbox: registry.setDefault, + ...component.finalDeps(GATEWAY_NAME, onboardSession, registry, runCaptureOpenshell), verifyWebSearchInsideSandbox, toSessionUpdates, removeLegacyCredentialsFile, diff --git a/src/lib/onboard/docker-driver-gateway-config-toml.test.ts b/src/lib/onboard/docker-driver-gateway-config-toml.test.ts index af045df8abd..d104945cac1 100644 --- a/src/lib/onboard/docker-driver-gateway-config-toml.test.ts +++ b/src/lib/onboard/docker-driver-gateway-config-toml.test.ts @@ -23,12 +23,14 @@ import { ensureDockerDriverGatewayJwtBundle, gatewayIdForStateDir, hasStateScopedSandboxNamespace, + NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV, NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV, prepareDockerDriverGatewayConfigEnv, } from "./docker-driver-gateway-config"; import { openRegularFileNoFollow } from "../adapters/fs/regular-file"; import { buildDockerDriverGatewayRuntimeMarker, + hashDockerDriverGatewayEnv, writeDockerDriverGatewayRuntimeMarker, } from "./docker-driver-gateway-runtime-marker"; import { prepareNativePodmanGatewayHostRuntime } from "./runtime-provider/podman-runtime-surfaces"; @@ -103,6 +105,103 @@ function writePreScopedGatewayConfig( } describe("docker-driver-gateway config TOML", () => { + it("renders only the fixed external component interceptor settings (#11340)", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-component-")); + try { + fs.chmodSync(stateDir, 0o700); + const env = baseGatewayEnv(stateDir); + prepareDockerDriverGatewayConfigEnv(env, stateDir, "/usr/bin/openshell-sandbox", { + externalComponent: { + componentId: "policy-governance", + interceptorSocketPath: "/run/user/1000/component/interceptor.sock", + }, + }); + + const toml = fs.readFileSync(path.join(stateDir, "openshell-gateway.toml"), "utf-8"); + expect(toml).toContain("[[openshell.gateway.interceptors]]"); + expect(toml).toContain('name = "policy-governance"'); + expect(toml).toContain('grpc_endpoint = "unix:///run/user/1000/component/interceptor.sock"'); + expect(toml).toContain("order = 10"); + expect(toml).toContain('failure_policy = "fail_closed"'); + expect(toml).toContain('binding_policy = "exact"'); + expect(toml).toContain('timeout = "500ms"'); + expect(toml).toContain("max_response_bytes = 1048576"); + expect(toml).toContain("max_patches = 32"); + expect(toml).toContain('rpc = "openshell.v1.OpenShell/CreateSandbox"'); + expect(toml).toContain('phases = ["modify_operation", "validate"]'); + expect(toml).toContain('rpc = "openshell.v1.OpenShell/UpdateConfig"'); + expect(toml).toContain('phases = ["validate"]'); + expect(toml).not.toMatch(/activation|credential|secret|token|password|api.?key/iu); + expect(toml).not.toContain("post_commit"); + expect(toml).not.toContain("provider_profile_sources"); + expect(env[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]).toMatch(/^[0-9a-f]{64}$/u); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("preserves or removes only NemoClaw-generated interceptor configuration (#11340)", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-component-")); + try { + fs.chmodSync(stateDir, 0o700); + const env = baseGatewayEnv(stateDir); + const component = { + componentId: "policy-governance", + interceptorSocketPath: "/run/user/1000/component/interceptor.sock", + }; + prepareDockerDriverGatewayConfigEnv(env, stateDir, "/usr/bin/openshell-sandbox", { + externalComponent: component, + }); + const componentRuntimeIdentity = hashDockerDriverGatewayEnv(env); + prepareDockerDriverGatewayConfigEnv(env, stateDir, "/usr/bin/openshell-sandbox"); + expect(fs.readFileSync(env.OPENSHELL_GATEWAY_CONFIG, "utf-8")).toContain( + "[[openshell.gateway.interceptors]]", + ); + expect(env[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]).toMatch(/^[0-9a-f]{64}$/u); + + prepareDockerDriverGatewayConfigEnv(env, stateDir, "/usr/bin/openshell-sandbox", { + externalComponent: null, + }); + expect(fs.readFileSync(env.OPENSHELL_GATEWAY_CONFIG, "utf-8")).not.toContain( + "[[openshell.gateway.interceptors]]", + ); + expect(env[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]).toBeUndefined(); + expect(hashDockerDriverGatewayEnv(env)).not.toBe(componentRuntimeIdentity); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it.each([ + ['failure_policy = "fail_closed"', 'failure_policy = "fail_open"'], + ['timeout = "500ms"', 'timeout = "30s"'], + ['phases = ["validate"]', 'phases = ["validate", "post_commit"]'], + ])("rejects edits to the fixed interceptor contract (%s) (#11340)", (allowed, replacement) => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-component-")); + try { + fs.chmodSync(stateDir, 0o700); + const env = baseGatewayEnv(stateDir); + prepareDockerDriverGatewayConfigEnv(env, stateDir, "/usr/bin/openshell-sandbox", { + externalComponent: { + componentId: "policy-governance", + interceptorSocketPath: "/run/user/1000/component/interceptor.sock", + }, + }); + const configPath = env.OPENSHELL_GATEWAY_CONFIG; + fs.writeFileSync( + configPath, + fs.readFileSync(configPath, "utf-8").replace(allowed, replacement), + { mode: 0o600 }, + ); + + expect(() => + prepareDockerDriverGatewayConfigEnv(env, stateDir, "/usr/bin/openshell-sandbox"), + ).toThrow(/interceptor configuration is invalid|does not match canonical content/iu); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + it("writes OpenShell 0.0.72 gateway JWT config into the managed state dir", () => { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-config-")); try { diff --git a/src/lib/onboard/docker-driver-gateway-config.ts b/src/lib/onboard/docker-driver-gateway-config.ts index dcc80d45513..471cc525a88 100644 --- a/src/lib/onboard/docker-driver-gateway-config.ts +++ b/src/lib/onboard/docker-driver-gateway-config.ts @@ -11,6 +11,7 @@ import { } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { parse as parseToml } from "smol-toml"; import { type OpenRegularFile, openRegularFileNoFollow } from "../adapters/fs/regular-file"; import { @@ -18,6 +19,7 @@ import { ensureDockerDriverGatewayJwtBundle, } from "./docker-driver-gateway-jwt-bundle"; import { parseDockerDriverGatewayRuntimeMarker } from "./docker-driver-gateway-runtime-marker"; +import type { ExternalComponentDeclaration } from "./external-component"; import type { RuntimeProviderGatewayHostRuntime } from "./runtime-provider/contract"; import { resolveConfiguredRuntimeProvider, @@ -33,6 +35,8 @@ export const DOCKER_DRIVER_GATEWAY_CONFIG_NAME = "openshell-gateway.toml"; export const DOCKER_DRIVER_GATEWAY_JWT_TTL_SECS = 0; const LEGACY_DOCKER_DRIVER_GATEWAY_JWT_TTL_SECS = 3600; const PRE_AUTH_DOCKER_DRIVER_GATEWAY_VERSION = "0.0.44"; +export const NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV = + "NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY"; export const NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV = "NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE"; interface FileIdentity { @@ -103,6 +107,7 @@ function alternateGatewayRuntimeProjection( interface LegacyGatewayIdentity { configProof: ExistingConfigProof; + externalComponent: ExternalComponentGatewayConfiguration | null; gatewayId: string; jwtProof: LegacyJwtBundleProof; kind: "legacy"; @@ -113,11 +118,25 @@ type DockerDriverGatewayIdentity = | LegacyGatewayIdentity | { configProof: ExistingConfigProof | null; + externalComponent: ExternalComponentGatewayConfiguration | null; kind: "scoped"; gatewayId: string; sandboxNamespace: string; }; +export type ExternalComponentGatewayConfiguration = Pick< + ExternalComponentDeclaration, + "componentId" | "interceptorSocketPath" +>; + +function externalComponentGatewayIdentity( + component: ExternalComponentGatewayConfiguration, +): string { + return createHash("sha256") + .update(JSON.stringify([component.componentId, component.interceptorSocketPath])) + .digest("hex"); +} + function fileIdentity(stats: fs.Stats): FileIdentity { return { dev: stats.dev, ino: stats.ino, mode: stats.mode & 0o777, uid: stats.uid }; } @@ -419,6 +438,66 @@ function asTomlTable(value: unknown): Record | null { : null; } +function parseExternalComponentGatewayConfiguration( + value: unknown, +): ExternalComponentGatewayConfiguration | null { + if (value === undefined) return null; + if (!Array.isArray(value) || value.length !== 1) { + throw new Error("the interceptor configuration is invalid"); + } + const interceptor = asTomlTable(value[0]); + const bindings = Array.isArray(interceptor?.bindings) ? interceptor.bindings : null; + const create = asTomlTable(bindings?.[0]); + const update = asTomlTable(bindings?.[1]); + if ( + !interceptor || + Object.keys(interceptor).sort().join(",") !== + [ + "binding_policy", + "bindings", + "failure_policy", + "grpc_endpoint", + "max_patches", + "max_response_bytes", + "name", + "order", + "timeout", + ] + .sort() + .join(",") || + typeof interceptor.name !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(interceptor.name) || + typeof interceptor.grpc_endpoint !== "string" || + !interceptor.grpc_endpoint.startsWith("unix:///") || + interceptor.order !== 10 || + interceptor.failure_policy !== "fail_closed" || + interceptor.binding_policy !== "exact" || + interceptor.timeout !== "500ms" || + interceptor.max_response_bytes !== 1_048_576 || + interceptor.max_patches !== 32 || + bindings?.length !== 2 || + !create || + create.rpc !== "openshell.v1.OpenShell/CreateSandbox" || + !isDeepStrictEqual(create.phases, ["modify_operation", "validate"]) || + !update || + update.rpc !== "openshell.v1.OpenShell/UpdateConfig" || + !isDeepStrictEqual(update.phases, ["validate"]) + ) { + throw new Error("the interceptor configuration is invalid"); + } + const interceptorSocketPath = interceptor.grpc_endpoint.slice("unix://".length); + if ( + interceptorSocketPath.length === 0 || + interceptorSocketPath.length > 4096 || + interceptorSocketPath.includes("\0") || + !path.isAbsolute(interceptorSocketPath) || + path.normalize(interceptorSocketPath) !== interceptorSocketPath + ) { + throw new Error("the interceptor configuration is invalid"); + } + return { componentId: interceptor.name, interceptorSocketPath }; +} + function assignStringEnv(env: Record, key: string, value: unknown): void { if (typeof value === "string") env[key] = value; } @@ -535,6 +614,12 @@ function existingGatewayIdentityFromConfig( const gatewayJwt = asTomlTable(gateway?.gateway_jwt); const drivers = asTomlTable(openshell?.drivers); const driverConfig = asTomlTable(drivers?.[driver]); + let externalComponent: ExternalComponentGatewayConfiguration | null; + try { + externalComponent = parseExternalComponentGatewayConfiguration(gateway?.interceptors); + } catch { + throw ambiguousGatewayConfig(configPath, "the interceptor configuration is invalid"); + } if (!driverConfig && parsed && openshell && gateway && gatewayJwt && drivers) { const configuredDriver = Object.entries(drivers).find( ([candidate, config]) => candidate !== driver && asTomlTable(config) !== null, @@ -632,6 +717,7 @@ function existingGatewayIdentityFromConfig( typeof namespace === "string" ? namespace : null, canonicalGatewayJwtTtl, configuredRuntime, + externalComponent, ); if (originalToml !== canonicalToml) { throw ambiguousGatewayConfig( @@ -643,6 +729,7 @@ function existingGatewayIdentityFromConfig( if (isLegacy) { const identity: LegacyGatewayIdentity = { configProof, + externalComponent, gatewayId: legacyGatewayId, jwtProof: openOwnedLegacyJwtBundle(stateDir, state.uid), kind: "legacy", @@ -655,6 +742,7 @@ function existingGatewayIdentityFromConfig( if (isScoped) { const identity: DockerDriverGatewayIdentity = { configProof, + externalComponent, gatewayId: scopedGatewayId, kind: "scoped", sandboxNamespace: scopedGatewayId, @@ -683,7 +771,13 @@ function resolveDockerDriverGatewayIdentity( ); if (existing) return existing; const gatewayId = gatewayIdForStateDir(stateDir); - return { configProof: null, kind: "scoped", gatewayId, sandboxNamespace: gatewayId }; + return { + configProof: null, + externalComponent: null, + kind: "scoped", + gatewayId, + sandboxNamespace: gatewayId, + }; } /** Prove that a NemoClaw-owned Docker gateway config uses its state-scoped namespace. */ @@ -717,6 +811,7 @@ function buildDockerDriverGatewayConfigTomlForIdentity( sandboxNamespace: string | null = gatewayId, gatewayJwtTtlSecs = DOCKER_DRIVER_GATEWAY_JWT_TTL_SECS, projectedRuntime?: RuntimeProviderGatewayHostRuntime, + externalComponent?: ExternalComponentGatewayConfiguration | null, ): string { const runtime = resolveGatewayRuntimeProjection(gatewayEnv, projectedRuntime); const driver = runtime.openShellDriver; @@ -791,6 +886,29 @@ function buildDockerDriverGatewayConfigTomlForIdentity( ); } + if (externalComponent) { + sections.push( + "[[openshell.gateway.interceptors]]", + `name = ${tomlString(externalComponent.componentId)}`, + `grpc_endpoint = ${tomlString(`unix://${externalComponent.interceptorSocketPath}`)}`, + "order = 10", + 'failure_policy = "fail_closed"', + 'binding_policy = "exact"', + 'timeout = "500ms"', + "max_response_bytes = 1048576", + "max_patches = 32", + "", + "[[openshell.gateway.interceptors.bindings]]", + 'rpc = "openshell.v1.OpenShell/CreateSandbox"', + 'phases = ["modify_operation", "validate"]', + "", + "[[openshell.gateway.interceptors.bindings]]", + 'rpc = "openshell.v1.OpenShell/UpdateConfig"', + 'phases = ["validate"]', + "", + ); + } + sections.push(`[openshell.drivers.${driver}]`); if (dockerConfig) sections.push(dockerConfig); sections.push(""); @@ -804,6 +922,7 @@ export function buildDockerDriverGatewayConfigToml( jwtBundle?: DockerDriverGatewayJwtBundle | null, gatewayId = "nemoclaw", runtime?: RuntimeProviderGatewayHostRuntime, + externalComponent?: ExternalComponentGatewayConfiguration | null, ): string { return buildDockerDriverGatewayConfigTomlForIdentity( gatewayEnv, @@ -813,6 +932,7 @@ export function buildDockerDriverGatewayConfigToml( gatewayId, DOCKER_DRIVER_GATEWAY_JWT_TTL_SECS, runtime, + externalComponent, ); } @@ -822,8 +942,13 @@ function writeDockerDriverGatewayConfigWithIdentity( sandboxBin: string | null | undefined, identity: DockerDriverGatewayIdentity, runtime: RuntimeProviderGatewayHostRuntime, + requestedExternalComponent?: ExternalComponentGatewayConfiguration | null, ): string { const configPath = path.join(stateDir, DOCKER_DRIVER_GATEWAY_CONFIG_NAME); + const externalComponent = + requestedExternalComponent === undefined + ? identity.externalComponent + : requestedExternalComponent; if (identity.kind === "legacy") { try { assertExistingConfigProof(identity.configProof); @@ -838,6 +963,7 @@ function writeDockerDriverGatewayConfigWithIdentity( identity.sandboxNamespace, DOCKER_DRIVER_GATEWAY_JWT_TTL_SECS, runtime, + externalComponent, ), 0o600, () => { @@ -864,6 +990,7 @@ function writeDockerDriverGatewayConfigWithIdentity( jwtBundle, identity.gatewayId, runtime, + externalComponent, ), 0o600, identity.configProof ? () => assertExistingConfigProof(identity.configProof!) : undefined, @@ -879,6 +1006,7 @@ export function writeDockerDriverGatewayConfig( gatewayEnv: Record, sandboxBin?: string | null, projectedRuntime?: RuntimeProviderGatewayHostRuntime, + externalComponent?: ExternalComponentGatewayConfiguration | null, ): string { const runtime = resolveGatewayRuntimeProjection(gatewayEnv, projectedRuntime); return writeDockerDriverGatewayConfigWithIdentity( @@ -887,6 +1015,7 @@ export function writeDockerDriverGatewayConfig( sandboxBin, resolveDockerDriverGatewayIdentity(stateDir, gatewayEnv, runtime), runtime, + externalComponent, ); } @@ -897,6 +1026,7 @@ export function prepareDockerDriverGatewayConfigEnv( options: { allowOpenShell0044PreAuthDatabase?: boolean; gatewayRuntime?: RuntimeProviderGatewayHostRuntime; + externalComponent?: ExternalComponentGatewayConfiguration | null; } = {}, ): Record { const runtime = resolveGatewayRuntimeProjection(gatewayEnv, options.gatewayRuntime); @@ -918,13 +1048,24 @@ export function prepareDockerDriverGatewayConfigEnv( } throw error; } + const externalComponent = + options.externalComponent === undefined + ? identity.externalComponent + : options.externalComponent; gatewayEnv.OPENSHELL_GATEWAY_CONFIG = writeDockerDriverGatewayConfigWithIdentity( stateDir, gatewayEnv, sandboxBin, identity, runtime, + externalComponent, ); + if (externalComponent) { + gatewayEnv[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV] = + externalComponentGatewayIdentity(externalComponent); + } else { + delete gatewayEnv[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]; + } if (runtime.gatewayConfig.sandboxNamespace === "omitted") { delete gatewayEnv[NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV]; } else { diff --git a/src/lib/onboard/docker-driver-gateway-env.ts b/src/lib/onboard/docker-driver-gateway-env.ts index d52f3e0f60e..fbfe6aa0660 100644 --- a/src/lib/onboard/docker-driver-gateway-env.ts +++ b/src/lib/onboard/docker-driver-gateway-env.ts @@ -21,8 +21,10 @@ import { DEFAULT_GATEWAY_PORT, GATEWAY_PORT } from "../core/ports"; import { isSupportedGatewayDockerHost } from "../domain/docker-host"; import { DOCKER_DRIVER_GATEWAY_JWT_TTL_SECS, + NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV, NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV, prepareDockerDriverGatewayConfigEnv, + type ExternalComponentGatewayConfiguration, } from "./docker-driver-gateway-config"; import { buildDockerDriverGatewayLocalTlsEnv } from "./docker-driver-gateway-local-tls"; import { @@ -74,6 +76,7 @@ export const DOCKER_DRIVER_GATEWAY_RUNTIME_ENV_KEYS = [ "OPENSHELL_VM_DRIVER_STATE_DIR", "OPENSHELL_DRIVER_DIR", "NEMOCLAW_DOCKER_ENABLE_BIND_MOUNTS", + NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV, NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV, "NETAVARK_FW", ] as const; @@ -89,6 +92,7 @@ export interface BuildDockerDriverGatewayEnvOptions { getDockerSupervisorImage: () => string; resolveSandboxBin: () => string | null; enableBindMounts?: boolean; + externalComponent?: ExternalComponentGatewayConfiguration | null; } function preparePortableGatewayHostRuntime( @@ -384,12 +388,7 @@ export function assertDockerDriverGatewayAuthConfigSafe( platform: NodeJS.Platform = process.platform, gatewayRuntime?: RuntimeProviderGatewayHostRuntime, ): void { - assertDockerDriverGatewayBindAddressSafe( - gatewayEnv, - environment, - platform, - gatewayRuntime, - ); + assertDockerDriverGatewayBindAddressSafe(gatewayEnv, environment, platform, gatewayRuntime); const configPath = gatewayEnv.OPENSHELL_GATEWAY_CONFIG?.trim(); if (!configPath) { throw new Error("OpenShell Docker-driver gateway requires OPENSHELL_GATEWAY_CONFIG"); @@ -434,6 +433,7 @@ export function buildDockerDriverGatewayEnv({ getDockerSupervisorImage, resolveSandboxBin, enableBindMounts = false, + externalComponent, }: BuildDockerDriverGatewayEnvOptions): Record { const portable = isPortableExperimentalProfile(); const runtime = @@ -490,6 +490,7 @@ export function buildDockerDriverGatewayEnv({ allowOpenShell0044PreAuthDatabase: process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE === "1", gatewayRuntime: runtime, + externalComponent, }); return env; } diff --git a/src/lib/onboard/docker-driver-gateway-runtime.test.ts b/src/lib/onboard/docker-driver-gateway-runtime.test.ts index 53f766efa42..e299f556985 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.test.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.test.ts @@ -7,7 +7,10 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as dockerDriverGatewayEnv from "./docker-driver-gateway-env"; -import { gatewayIdForStateDir } from "./docker-driver-gateway-config"; +import { + gatewayIdForStateDir, + NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV, +} from "./docker-driver-gateway-config"; import { createDockerDriverGatewayRuntimeHelpers, type DockerDriverGatewayRuntimeDeps, @@ -560,6 +563,43 @@ describe("docker-driver gateway runtime helpers", () => { ).toBe("NEMOCLAW_DOCKER_ENABLE_BIND_MOUNTS=1 (expected )"); }); + it("marks a gateway stale when its external component identity differs (#11340)", () => { + const { helpers } = makeHelpers(); + expect( + helpers.getDockerDriverGatewayRuntimeDriftFromSnapshot({ + processEnv: { + OPENSHELL_DRIVERS: "docker", + [NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]: "prior-component", + }, + processExe: "/usr/bin/openshell-gateway", + desiredEnv: { + OPENSHELL_DRIVERS: "docker", + [NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]: "approved-component", + }, + gatewayBin: "/usr/bin/openshell-gateway", + })?.reason, + ).toBe( + `${NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV}=prior-component (expected approved-component)`, + ); + }); + + it("marks a gateway stale when its external component registration was removed (#11340)", () => { + const { helpers } = makeHelpers(); + expect( + helpers.getDockerDriverGatewayRuntimeDriftFromSnapshot({ + processEnv: { + OPENSHELL_DRIVERS: "docker", + [NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]: "prior-component", + }, + processExe: "/usr/bin/openshell-gateway", + desiredEnv: { OPENSHELL_DRIVERS: "docker" }, + gatewayBin: "/usr/bin/openshell-gateway", + })?.reason, + ).toBe( + `${NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV}=prior-component (expected )`, + ); + }); + it("reuses a systemd-owned gateway without detached cleanup identity (#6903)", () => { const pid = 12_350; const gatewayBin = "/usr/bin/openshell-gateway"; diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts index a934f07af52..2893e4e3db4 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -10,6 +10,7 @@ import { isSupportedGatewayDockerHost } from "../domain/docker-host"; import { gatewayIdForStateDir, NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV, + type ExternalComponentGatewayConfiguration, } from "./docker-driver-gateway-config"; import { createDockerDriverGatewayPortListenerHelpers, @@ -101,6 +102,7 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa getDockerDriverGatewayEnv( versionOutput?: string | null, platform?: NodeJS.Platform, + externalComponent?: ExternalComponentGatewayConfiguration | null, ): Record; getDockerDriverGatewayPid(): number | null; getDockerDriverGatewayPidFile(): string; @@ -246,6 +248,7 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa function getDockerDriverGatewayEnv( versionOutput: string | null = null, platform: NodeJS.Platform = process.platform, + externalComponent?: ExternalComponentGatewayConfiguration | null, ): Record { const dockerHost = process.env.DOCKER_HOST; let podmanSocketPath: string | undefined; @@ -267,6 +270,7 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa getDockerSupervisorImage: () => getOpenShellDockerSupervisorImage(versionOutput), resolveSandboxBin: resolveOpenShellSandboxBinary, enableBindMounts: deps.enableBindMounts?.() === true, + externalComponent, }); if (gatewayEnv.OPENSHELL_LOCAL_TLS_DIR) { process.env.OPENSHELL_LOCAL_TLS_DIR = gatewayEnv.OPENSHELL_LOCAL_TLS_DIR; @@ -357,7 +361,11 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa const actual = processEnv[key]; const desired = desiredEnv[key]; if (typeof desired !== "string") { - if (key === "NEMOCLAW_DOCKER_ENABLE_BIND_MOUNTS" && actual !== undefined) { + if ( + (key === "NEMOCLAW_DOCKER_ENABLE_BIND_MOUNTS" || + key === "NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY") && + actual !== undefined + ) { return { reason: `${key}=${actual} (expected )` }; } continue; diff --git a/src/lib/onboard/external-component/activation.test.ts b/src/lib/onboard/external-component/activation.test.ts new file mode 100644 index 00000000000..753ce4b847b --- /dev/null +++ b/src/lib/onboard/external-component/activation.test.ts @@ -0,0 +1,250 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import { EXTERNAL_COMPONENT_ACTIVATION_TIMEOUT_MS, type PreparedExternalComponent } from "./index"; +import { + activateExternalComponent, + parseExternalComponentHttpResponse, + sendExternalComponentActivation, + type ExternalComponentActivationProof, +} from "./activation"; + +const policyHash = `sha256:${"a".repeat(64)}`; +const identityFingerprint = `sha256:${"b".repeat(64)}`; + +function fixture(events: string[] = []) { + const component: PreparedExternalComponent = { + declaration: { + schemaVersion: 1, + componentId: "policy-governance", + interceptorSocketPath: "/run/user/1000/component/interceptor.sock", + activationSocketPath: "/run/user/1000/component/activation.sock", + }, + revalidateBeforeGateway: vi.fn(), + revalidateBeforeActivation: vi.fn(() => events.push("socket")), + }; + const proof: ExternalComponentActivationProof = { + gatewayName: "nemoclaw", + sandboxId: "sandbox-123", + sandboxIdentityFingerprint: identityFingerprint, + lifecycleGeneration: "generation-1", + policySource: "sandbox", + policyHash, + policyActiveVersion: 7, + revalidate: vi.fn((operation) => events.push(operation)), + }; + return { component, proof }; +} + +function responseFor(body: string, overrides: Record = {}): string { + const request = JSON.parse(body) as { + activationId: string; + componentId: string; + sandbox: { id: string }; + policy: { hash: string }; + }; + return JSON.stringify({ + schemaVersion: 1, + activationId: request.activationId, + componentId: request.componentId, + sandboxId: request.sandbox.id, + policyHash: request.policy.hash, + result: "activated", + ...overrides, + }); +} + +describe("external component activation", () => { + it("keeps the v1 activation timeout fixed at 30 seconds (#11340)", () => { + expect(EXTERNAL_COMPONENT_ACTIVATION_TIMEOUT_MS).toBe(30_000); + }); + + it("uses one HTTP request over the declared activation socket (#11340)", async () => { + const root = fs.mkdtempSync(path.join("/tmp", "nc-component-http-")); + const socketPath = path.join(root, "activation.sock"); + let resolveRequest!: (request: string) => void; + const received = new Promise((resolve) => { + resolveRequest = resolve; + }); + const server = net.createServer({ allowHalfOpen: true }, (socket) => { + const chunks: Buffer[] = []; + socket.on("data", (chunk: Buffer) => chunks.push(chunk)); + socket.on("end", () => { + const request = Buffer.concat(chunks).toString("utf-8"); + resolveRequest(request); + const responseBody = '{"result":"activated"}'; + socket.end( + `HTTP/1.1 200 OK\r\nContent-Length: ${String(Buffer.byteLength(responseBody))}\r\nConnection: close\r\n\r\n${responseBody}`, + ); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + + try { + const requestBody = '{"schemaVersion":1}'; + await expect(sendExternalComponentActivation(socketPath, requestBody)).resolves.toBe( + '{"result":"activated"}', + ); + const request = await received; + expect(request).toContain("POST /v1/activate HTTP/1.1\r\n"); + expect(request).toContain("Content-Type: application/json\r\n"); + expect(request).toContain(`Content-Length: ${String(Buffer.byteLength(requestBody))}\r\n`); + expect(request.endsWith(`\r\n\r\n${requestBody}`)).toBe(true); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("accepts one bounded HTTP 200 JSON response (#11340)", () => { + const body = '{"result":"activated"}'; + const raw = Buffer.from( + `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${String(Buffer.byteLength(body))}\r\nConnection: close\r\n\r\n${body}`, + ); + + expect(parseExternalComponentHttpResponse(raw)).toBe(body); + }); + + it.each([ + ["a non-200 status", "HTTP/1.1 500 Error\r\nContent-Length: 0\r\n\r\n"], + ["duplicate headers", "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nContent-Length: 0\r\n\r\n"], + [ + "chunked bodies", + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Length: 0\r\n\r\n", + ], + [ + "extra responses", + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}", + ], + ])("rejects %s (#11340)", (_title, raw) => { + expect(() => parseExternalComponentHttpResponse(Buffer.from(raw))).toThrow("response_invalid"); + }); + + it("rejects response bodies above the fixed size limit (#11340)", () => { + const raw = Buffer.from( + "HTTP/1.1 200 OK\r\nContent-Length: 1048577\r\nConnection: close\r\n\r\n", + ); + + expect(() => parseExternalComponentHttpResponse(raw)).toThrow("response_oversized"); + }); + + it("hands off only verified identity and policy fields and revalidates after success (#11340)", async () => { + const events: string[] = []; + const { component, proof } = fixture(events); + const transport = vi.fn(async (_socketPath: string, body: string) => { + events.push("request"); + const request = JSON.parse(body) as Record; + expect(Object.keys(request)).toEqual([ + "schemaVersion", + "activationId", + "componentId", + "gateway", + "sandbox", + "policy", + ]); + expect(request).toMatchObject({ + schemaVersion: 1, + componentId: "policy-governance", + gateway: { name: "nemoclaw" }, + sandbox: { + id: "sandbox-123", + identityFingerprint, + lifecycleGeneration: "generation-1", + }, + policy: { source: "sandbox", hash: policyHash, activeVersion: 7 }, + }); + expect(body).not.toMatch(/credential|secret|token|password|api.?key/iu); + return responseFor(body); + }); + + await expect(activateExternalComponent(component, proof, transport)).resolves.toEqual({ + kind: "activated", + }); + expect(transport).toHaveBeenCalledWith( + component.declaration.activationSocketPath, + expect.any(String), + ); + expect(events).toEqual(["socket", "before_handoff", "request", "socket", "after_activation"]); + }); + + it("returns failed activation for one exact rejection response (#11340)", async () => { + const { component, proof } = fixture(); + const transport = async (_socketPath: string, body: string) => + responseFor(body, { result: "rejected" }); + + const result = await activateExternalComponent(component, proof, transport); + + expect(result).toMatchObject({ kind: "rejected" }); + expect(proof.revalidate).toHaveBeenCalledExactlyOnceWith("before_handoff"); + }); + + it.each([ + ["timeouts", new Error("timeout"), "timeout"], + ["disconnects", new Error("connection"), "connection"], + ["oversized responses", new Error("response_oversized"), "response_oversized"], + ["invalid HTTP responses", new Error("response_invalid"), "response_invalid"], + ])("returns ambiguous activation for %s (#11340)", async (_title, error, reason) => { + const { component, proof } = fixture(); + + await expect( + activateExternalComponent(component, proof, async () => Promise.reject(error)), + ).resolves.toMatchObject({ kind: "ambiguous", reason }); + }); + + it.each([ + ["malformed JSON", () => "{"], + ["unknown fields", (body: string) => responseFor(body, { message: "component text" })], + [ + "duplicate fields", + (body: string) => + responseFor(body).replace( + '"result":"activated"', + '"result":"activated","result":"activated"', + ), + ], + ["mismatched evidence", (body: string) => responseFor(body, { sandboxId: "replacement" })], + ])("returns ambiguous activation for %s (#11340)", async (_title, respond) => { + const { component, proof } = fixture(); + + await expect( + activateExternalComponent(component, proof, async (_socketPath, body) => respond(body)), + ).resolves.toMatchObject({ kind: "ambiguous", reason: "response_invalid" }); + }); + + it("does not send identity when pre-handoff proof changes (#11340)", async () => { + const { component, proof } = fixture(); + proof.revalidate = vi.fn(() => { + throw new Error("changed"); + }); + const transport = vi.fn(); + + await expect(activateExternalComponent(component, proof, transport)).resolves.toMatchObject({ + kind: "ambiguous", + reason: "evidence_mismatch", + }); + expect(transport).not.toHaveBeenCalled(); + }); + + it("does not report success when post-response proof changes (#11340)", async () => { + const { component, proof } = fixture(); + proof.revalidate = vi + .fn() + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => { + throw new Error("changed"); + }); + + await expect( + activateExternalComponent(component, proof, async (_socketPath, body) => responseFor(body)), + ).resolves.toMatchObject({ kind: "ambiguous", reason: "evidence_mismatch" }); + }); +}); diff --git a/src/lib/onboard/external-component/activation.ts b/src/lib/onboard/external-component/activation.ts new file mode 100644 index 00000000000..ef35eafb51e --- /dev/null +++ b/src/lib/onboard/external-component/activation.ts @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from "node:crypto"; +import net from "node:net"; + +import { + EXTERNAL_COMPONENT_ACTIVATION_TIMEOUT_MS, + EXTERNAL_COMPONENT_MAX_RESPONSE_BYTES, + EXTERNAL_COMPONENT_SCHEMA_VERSION, + parseStrictExternalComponentJson, + type PreparedExternalComponent, +} from "./index"; + +const RESPONSE_HEADER_MAX_BYTES = 16 * 1024; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const SHA256_IDENTITY_PATTERN = /^sha256:[0-9a-f]{64}$/u; + +export type ExternalComponentActivationResult = + | { readonly kind: "activated" } + | { readonly kind: "rejected"; readonly activationId: string } + | { + readonly kind: "ambiguous"; + readonly activationId: string; + readonly reason: + | "connection" + | "evidence_mismatch" + | "response_invalid" + | "response_oversized" + | "timeout"; + }; + +export interface ExternalComponentActivationProof { + readonly gatewayName: string; + readonly sandboxId: string; + readonly sandboxIdentityFingerprint: string; + readonly lifecycleGeneration: string; + readonly policySource: "sandbox" | "global"; + readonly policyHash: string; + readonly policyActiveVersion: number; + revalidate(operation: "before_handoff" | "after_activation"): void; +} + +interface ActivationResponse { + readonly schemaVersion: typeof EXTERNAL_COMPONENT_SCHEMA_VERSION; + readonly activationId: string; + readonly componentId: string; + readonly sandboxId: string; + readonly policyHash: string; + readonly result: "activated" | "rejected"; +} + +type ActivationTransport = (socketPath: string, body: string) => Promise; + +export function createExternalComponentActivationId(): string { + return randomUUID(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseActivationResponse(source: string): ActivationResponse | null { + let value: unknown; + try { + value = parseStrictExternalComponentJson(source); + } catch { + return null; + } + if (!isRecord(value)) return null; + const fields = [ + "schemaVersion", + "activationId", + "componentId", + "sandboxId", + "policyHash", + "result", + ]; + if ( + Object.keys(value).length !== fields.length || + fields.some((field) => !Object.hasOwn(value, field)) || + value.schemaVersion !== EXTERNAL_COMPONENT_SCHEMA_VERSION || + typeof value.activationId !== "string" || + !UUID_PATTERN.test(value.activationId) || + typeof value.componentId !== "string" || + typeof value.sandboxId !== "string" || + typeof value.policyHash !== "string" || + !SHA256_IDENTITY_PATTERN.test(value.policyHash) || + (value.result !== "activated" && value.result !== "rejected") + ) { + return null; + } + return value as unknown as ActivationResponse; +} + +export function parseExternalComponentHttpResponse(raw: Buffer): string { + const headerEnd = raw.indexOf("\r\n\r\n"); + if (headerEnd < 0 || headerEnd > RESPONSE_HEADER_MAX_BYTES) { + throw new Error("response_invalid"); + } + const headerText = raw.subarray(0, headerEnd).toString("ascii"); + const lines = headerText.split("\r\n"); + if (!/^HTTP\/1\.1 200(?: [^\r\n]*)?$/u.test(lines.shift() ?? "")) { + throw new Error("response_invalid"); + } + const headers = new Map(); + for (const line of lines) { + const match = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*([^\r\n]*)$/u.exec(line); + if (!match?.[1] || match[2] === undefined) throw new Error("response_invalid"); + const name = match[1].toLowerCase(); + if (headers.has(name)) throw new Error("response_invalid"); + headers.set(name, match[2].trim()); + } + if (headers.has("transfer-encoding")) throw new Error("response_invalid"); + const rawLength = headers.get("content-length"); + if (!rawLength || !/^(?:0|[1-9]\d*)$/u.test(rawLength)) { + throw new Error("response_invalid"); + } + const length = Number(rawLength); + if (!Number.isSafeInteger(length) || length > EXTERNAL_COMPONENT_MAX_RESPONSE_BYTES) { + throw new Error("response_oversized"); + } + const bodyStart = headerEnd + 4; + if (raw.length !== bodyStart + length) throw new Error("response_invalid"); + return raw.subarray(bodyStart).toString("utf-8"); +} + +export function sendExternalComponentActivation(socketPath: string, body: string): Promise { + return new Promise((resolve, reject) => { + const request = Buffer.from(body, "utf-8"); + const socket = net.createConnection({ path: socketPath }); + const chunks: Buffer[] = []; + let received = 0; + let settled = false; + const finish = (error?: Error, response?: string): void => { + if (settled) return; + settled = true; + clearTimeout(deadline); + socket.destroy(); + if (error) reject(error); + else resolve(response ?? ""); + }; + const deadline = setTimeout( + () => finish(new Error("timeout")), + EXTERNAL_COMPONENT_ACTIVATION_TIMEOUT_MS, + ); + deadline.unref(); + socket.once("connect", () => { + socket.end( + `POST /v1/activate HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: ${String(request.length)}\r\nConnection: close\r\n\r\n${body}`, + ); + }); + socket.on("data", (chunk: Buffer) => { + received += chunk.length; + if (received > RESPONSE_HEADER_MAX_BYTES + EXTERNAL_COMPONENT_MAX_RESPONSE_BYTES + 4) { + finish(new Error("response_oversized")); + return; + } + chunks.push(chunk); + }); + socket.once("end", () => { + try { + finish(undefined, parseExternalComponentHttpResponse(Buffer.concat(chunks))); + } catch (error) { + finish(error instanceof Error ? error : new Error("response_invalid")); + } + }); + socket.once("error", () => finish(new Error("connection"))); + }); +} + +export async function activateExternalComponent( + component: PreparedExternalComponent, + proof: ExternalComponentActivationProof, + transport: ActivationTransport = sendExternalComponentActivation, + activationId = createExternalComponentActivationId(), +): Promise { + const declaration = component.declaration; + const body = JSON.stringify({ + schemaVersion: EXTERNAL_COMPONENT_SCHEMA_VERSION, + activationId, + componentId: declaration.componentId, + gateway: { name: proof.gatewayName }, + sandbox: { + id: proof.sandboxId, + identityFingerprint: proof.sandboxIdentityFingerprint, + lifecycleGeneration: proof.lifecycleGeneration, + }, + policy: { + source: proof.policySource, + hash: proof.policyHash, + activeVersion: proof.policyActiveVersion, + }, + }); + try { + component.revalidateBeforeActivation(); + proof.revalidate("before_handoff"); + } catch { + return { kind: "ambiguous", activationId, reason: "evidence_mismatch" }; + } + let rawResponse: string; + try { + rawResponse = await transport(declaration.activationSocketPath, body); + } catch (error) { + const reason = + error instanceof Error && error.message === "timeout" + ? "timeout" + : error instanceof Error && error.message === "response_oversized" + ? "response_oversized" + : error instanceof Error && error.message === "response_invalid" + ? "response_invalid" + : "connection"; + return { kind: "ambiguous", activationId, reason }; + } + const response = parseActivationResponse(rawResponse); + if ( + !response || + response.activationId !== activationId || + response.componentId !== declaration.componentId || + response.sandboxId !== proof.sandboxId || + response.policyHash !== proof.policyHash + ) { + return { kind: "ambiguous", activationId, reason: "response_invalid" }; + } + if (response.result === "rejected") return { kind: "rejected", activationId }; + try { + component.revalidateBeforeActivation(); + proof.revalidate("after_activation"); + } catch { + return { kind: "ambiguous", activationId, reason: "evidence_mismatch" }; + } + return { kind: "activated" }; +} diff --git a/src/lib/onboard/external-component/index.test.ts b/src/lib/onboard/external-component/index.test.ts new file mode 100644 index 00000000000..9b7ec190b46 --- /dev/null +++ b/src/lib/onboard/external-component/index.test.ts @@ -0,0 +1,359 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import net from "node:net"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + ExternalComponentContractError, + loadExternalComponentDeclaration, + parseExternalComponentDeclaration, +} from "./index"; + +const roots: string[] = []; +const servers: net.Server[] = []; + +afterEach(async () => { + await Promise.all( + servers.splice(0).map( + (server) => + new Promise((resolve) => { + server.close(() => resolve()); + }), + ), + ); + for (const root of roots.splice(0)) fs.rmSync(root, { force: true, recursive: true }); +}); + +function expectReason(action: () => unknown, code: string): void { + try { + action(); + throw new Error("expected external component validation to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ExternalComponentContractError); + expect((error as ExternalComponentContractError).code).toBe(code); + } +} + +function validJson(overrides: Record = {}): string { + return JSON.stringify({ + schemaVersion: 1, + componentId: "policy-governance", + interceptorSocketPath: "/run/user/1000/component/interceptor.sock", + activationSocketPath: "/run/user/1000/component/activation.sock", + ...overrides, + }); +} + +async function listen(socketPath: string): Promise { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + fs.chmodSync(socketPath, 0o600); + servers.push(server); + return server; +} + +async function closeServer(server: net.Server): Promise { + await new Promise((resolve) => server.close(() => resolve())); + servers.splice(servers.indexOf(server), 1); +} + +async function preparedFixture() { + const root = fs.mkdtempSync(path.join(path.dirname(process.cwd()), "nc-component-test-")); + roots.push(root); + fs.chmodSync(root, 0o700); + const homeDirectory = path.join(root, "home"); + const configDirectory = path.join(homeDirectory, ".config", "nemoclaw"); + const runtimeDirectory = path.join(root, "runtime"); + fs.mkdirSync(configDirectory, { mode: 0o700, recursive: true }); + fs.mkdirSync(runtimeDirectory, { mode: 0o700, recursive: true }); + const interceptorSocketPath = path.join(runtimeDirectory, "interceptor.sock"); + const activationSocketPath = path.join(runtimeDirectory, "activation.sock"); + const interceptorServer = await listen(interceptorSocketPath); + const activationServer = await listen(activationSocketPath); + const declarationPath = path.join(configDirectory, "external-component.json"); + fs.writeFileSync(declarationPath, validJson({ interceptorSocketPath, activationSocketPath }), { + mode: 0o600, + }); + fs.chmodSync(declarationPath, 0o600); + return { + activationServer, + activationSocketPath, + declarationPath, + homeDirectory, + interceptorServer, + interceptorSocketPath, + runtimeDirectory, + }; +} + +describe("external component declaration", () => { + it("accepts the exact secret-free v1 fields (#11340)", () => { + expect(parseExternalComponentDeclaration(validJson())).toEqual({ + schemaVersion: 1, + componentId: "policy-governance", + interceptorSocketPath: "/run/user/1000/component/interceptor.sock", + activationSocketPath: "/run/user/1000/component/activation.sock", + }); + }); + + it.each([ + ["invalid JSON", "{", "declaration_invalid"], + [ + "duplicate keys", + '{"schemaVersion":1,"schemaVersion":1,"componentId":"component","interceptorSocketPath":"/run/component/interceptor.sock","activationSocketPath":"/run/component/activation.sock"}', + "declaration_duplicate_key", + ], + ["unknown fields", validJson({ command: "run" }), "declaration_unknown_field"], + ["unsupported schemas", validJson({ schemaVersion: 2 }), "schema_unsupported"], + [ + "missing fields", + JSON.stringify({ schemaVersion: 1, componentId: "component" }), + "declaration_invalid", + ], + [ + "relative endpoints", + validJson({ interceptorSocketPath: "interceptor.sock" }), + "declaration_invalid", + ], + [ + "one shared endpoint", + validJson({ activationSocketPath: "/run/user/1000/component/interceptor.sock" }), + "declaration_invalid", + ], + ])("rejects %s (#11340)", (_title, source, code) => { + expectReason(() => parseExternalComponentDeclaration(source), code); + }); + + it("accepts current-user files and sockets and revalidates their identity (#11340)", async () => { + const fixture = await preparedFixture(); + + const prepared = loadExternalComponentDeclaration({ + declarationPath: fixture.declarationPath, + homeDirectory: fixture.homeDirectory, + platform: "linux", + }); + + expect(prepared?.declaration.componentId).toBe("policy-governance"); + expect(() => prepared?.revalidateBeforeGateway()).not.toThrow(); + expect(() => prepared?.revalidateBeforeActivation()).not.toThrow(); + }); + + it("rejects a declaration that is not valid UTF-8 (#11340)", async () => { + const fixture = await preparedFixture(); + fs.writeFileSync(fixture.declarationPath, Buffer.from([0x7b, 0x22, 0xff, 0x22, 0x7d]), { + mode: 0o600, + }); + + expectReason( + () => + loadExternalComponentDeclaration({ + declarationPath: fixture.declarationPath, + homeDirectory: fixture.homeDirectory, + platform: "linux", + }), + "declaration_invalid", + ); + }); + + it("leaves onboarding unchanged when no declaration exists (#11340)", async () => { + const fixture = await preparedFixture(); + fs.unlinkSync(fixture.declarationPath); + + expect( + loadExternalComponentDeclaration({ + declarationPath: fixture.declarationPath, + homeDirectory: fixture.homeDirectory, + platform: "linux", + }), + ).toBeNull(); + }); + + it("rejects unsupported platforms only when a declaration exists (#11340)", async () => { + const fixture = await preparedFixture(); + + expectReason( + () => + loadExternalComponentDeclaration({ + declarationPath: fixture.declarationPath, + homeDirectory: fixture.homeDirectory, + platform: "darwin", + }), + "platform_unsupported", + ); + }); + + it("rejects symbolic-link declarations (#11340)", async () => { + const fixture = await preparedFixture(); + const target = path.join(fixture.homeDirectory, "declaration-target.json"); + fs.renameSync(fixture.declarationPath, target); + fs.symlinkSync(target, fixture.declarationPath); + + expectReason( + () => + loadExternalComponentDeclaration({ + declarationPath: fixture.declarationPath, + homeDirectory: fixture.homeDirectory, + platform: "linux", + }), + "declaration_symlink", + ); + }); + + it("rejects multiply linked declarations (#11340)", async () => { + const fixture = await preparedFixture(); + fs.linkSync(fixture.declarationPath, path.join(fixture.homeDirectory, "declaration-copy.json")); + + expectReason( + () => + loadExternalComponentDeclaration({ + declarationPath: fixture.declarationPath, + homeDirectory: fixture.homeDirectory, + platform: "linux", + }), + "declaration_ambiguous", + ); + }); + + it("rejects declaration ownership and mode changes (#11340)", async () => { + const fixture = await preparedFixture(); + expectReason( + () => + loadExternalComponentDeclaration({ + declarationPath: fixture.declarationPath, + homeDirectory: fixture.homeDirectory, + platform: "linux", + uid: (process.geteuid?.() ?? 0) + 1, + }), + "declaration_owner", + ); + + fs.chmodSync(fixture.declarationPath, 0o644); + expectReason( + () => + loadExternalComponentDeclaration({ + declarationPath: fixture.declarationPath, + homeDirectory: fixture.homeDirectory, + platform: "linux", + }), + "declaration_mode", + ); + }); + + it("rejects unsafe socket types, modes, and parent directories (#11340)", async () => { + const fixture = await preparedFixture(); + fs.chmodSync(fixture.activationSocketPath, 0o666); + expectReason( + () => + loadExternalComponentDeclaration({ + declarationPath: fixture.declarationPath, + homeDirectory: fixture.homeDirectory, + platform: "linux", + }), + "socket_mode", + ); + + fs.chmodSync(fixture.activationSocketPath, 0o600); + fs.chmodSync(fixture.runtimeDirectory, 0o777); + expectReason( + () => + loadExternalComponentDeclaration({ + declarationPath: fixture.declarationPath, + homeDirectory: fixture.homeDirectory, + platform: "linux", + }), + "socket_parent_unsafe", + ); + }); + + it("rejects a socket not owned by the effective user (#11340)", async () => { + const fixture = await preparedFixture(); + const realLstatSync = fs.lstatSync.bind(fs); + const lstatSync = vi.spyOn(fs, "lstatSync"); + lstatSync.mockImplementation((candidate) => { + const stat = realLstatSync(candidate); + return new Proxy(stat, { + get: (target, property, receiver) => + property === "uid" && String(candidate) === fixture.activationSocketPath + ? target.uid + 1 + : Reflect.get(target, property, receiver), + }); + }); + + try { + expectReason( + () => + loadExternalComponentDeclaration({ + declarationPath: fixture.declarationPath, + homeDirectory: fixture.homeDirectory, + platform: "linux", + }), + "socket_owner", + ); + } finally { + lstatSync.mockRestore(); + } + }); + + it("rejects non-socket and symbolic-link endpoints (#11340)", async () => { + const fileFixture = await preparedFixture(); + await closeServer(fileFixture.activationServer); + fs.rmSync(fileFixture.activationSocketPath, { force: true }); + fs.writeFileSync(fileFixture.activationSocketPath, "not a socket", { mode: 0o600 }); + + expectReason( + () => + loadExternalComponentDeclaration({ + declarationPath: fileFixture.declarationPath, + homeDirectory: fileFixture.homeDirectory, + platform: "linux", + }), + "socket_type", + ); + + const linkFixture = await preparedFixture(); + await closeServer(linkFixture.activationServer); + fs.rmSync(linkFixture.activationSocketPath, { force: true }); + fs.symlinkSync(linkFixture.interceptorSocketPath, linkFixture.activationSocketPath); + + expectReason( + () => + loadExternalComponentDeclaration({ + declarationPath: linkFixture.declarationPath, + homeDirectory: linkFixture.homeDirectory, + platform: "linux", + }), + "socket_type", + ); + }); + + it("rejects declaration or endpoint replacement before use (#11340)", async () => { + const fixture = await preparedFixture(); + const prepared = loadExternalComponentDeclaration({ + declarationPath: fixture.declarationPath, + homeDirectory: fixture.homeDirectory, + platform: "linux", + }); + fs.writeFileSync(fixture.declarationPath, "{}", { mode: 0o600 }); + + expectReason(() => prepared?.revalidateBeforeGateway(), "declaration_ambiguous"); + + const endpointFixture = await preparedFixture(); + const endpointPrepared = loadExternalComponentDeclaration({ + declarationPath: endpointFixture.declarationPath, + homeDirectory: endpointFixture.homeDirectory, + platform: "linux", + }); + await closeServer(endpointFixture.activationServer); + fs.rmSync(endpointFixture.activationSocketPath, { force: true }); + await listen(endpointFixture.activationSocketPath); + + expectReason(() => endpointPrepared?.revalidateBeforeActivation(), "socket_ambiguous"); + }); +}); diff --git a/src/lib/onboard/external-component/index.ts b/src/lib/onboard/external-component/index.ts new file mode 100644 index 00000000000..9f5af86f667 --- /dev/null +++ b/src/lib/onboard/external-component/index.ts @@ -0,0 +1,509 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { TextDecoder } from "node:util"; + +import { openRegularFileNoFollow } from "../../adapters/fs/regular-file"; + +function errnoCode(error: unknown): string | null { + return error instanceof Error && "code" in error + ? String((error as NodeJS.ErrnoException).code ?? "") + : null; +} + +export const EXTERNAL_COMPONENT_SCHEMA_VERSION = 1 as const; +export const EXTERNAL_COMPONENT_DECLARATION_NAME = "external-component.json"; +export const EXTERNAL_COMPONENT_ACTIVATION_TIMEOUT_MS = 30_000; +export const EXTERNAL_COMPONENT_MAX_RESPONSE_BYTES = 1_048_576; + +const DECLARATION_MAX_BYTES = 16 * 1024; +const COMPONENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); +const DECLARATION_FIELDS = new Set([ + "schemaVersion", + "componentId", + "interceptorSocketPath", + "activationSocketPath", +]); + +export type ExternalComponentContractErrorCode = + | "declaration_ambiguous" + | "declaration_duplicate_key" + | "declaration_invalid" + | "declaration_mode" + | "declaration_owner" + | "declaration_symlink" + | "declaration_unknown_field" + | "lifecycle_unsupported" + | "platform_unsupported" + | "schema_unsupported" + | "socket_ambiguous" + | "socket_mode" + | "socket_owner" + | "socket_parent_unsafe" + | "socket_type"; + +export class ExternalComponentContractError extends Error { + readonly code: ExternalComponentContractErrorCode; + + constructor(code: ExternalComponentContractErrorCode) { + super(`External component declaration rejected. Reason class: ${code}.`); + this.name = "ExternalComponentContractError"; + this.code = code; + } +} + +export interface ExternalComponentDeclaration { + readonly schemaVersion: typeof EXTERNAL_COMPONENT_SCHEMA_VERSION; + readonly componentId: string; + readonly interceptorSocketPath: string; + readonly activationSocketPath: string; +} + +interface FileIdentity { + readonly dev: number; + readonly ino: number; + readonly mode: number; + readonly uid: number; + readonly type: "directory" | "file" | "socket"; +} + +interface PathProof { + readonly identity: FileIdentity; + readonly path: string; +} + +interface DeclarationProof extends PathProof { + readonly bytes: Buffer; + readonly parents: readonly PathProof[]; +} + +interface EndpointProof extends PathProof { + readonly parents: readonly PathProof[]; +} + +export interface PreparedExternalComponent { + readonly declaration: ExternalComponentDeclaration; + revalidateBeforeGateway(): void; + revalidateBeforeActivation(): void; +} + +interface LoadExternalComponentOptions { + readonly declarationPath?: string; + readonly homeDirectory?: string; + readonly platform?: NodeJS.Platform; + readonly uid?: number; +} + +class StrictJsonParser { + private position = 0; + + constructor(private readonly source: string) {} + + parse(): unknown { + const value = this.value(); + this.whitespace(); + if (this.position !== this.source.length) this.invalid(); + return value; + } + + private value(): unknown { + this.whitespace(); + const next = this.source[this.position]; + if (next === "{") return this.object(); + if (next === "[") return this.array(); + if (next === '"') return this.string(); + if (next === "t") return this.literal("true", true); + if (next === "f") return this.literal("false", false); + if (next === "n") return this.literal("null", null); + return this.number(); + } + + private object(): Record { + this.position += 1; + const result = Object.create(null) as Record; + const keys = new Set(); + this.whitespace(); + if (this.source[this.position] === "}") { + this.position += 1; + return result; + } + while (this.position < this.source.length) { + this.whitespace(); + if (this.source[this.position] !== '"') this.invalid(); + const key = this.string(); + if (keys.has(key)) { + throw new ExternalComponentContractError("declaration_duplicate_key"); + } + keys.add(key); + this.whitespace(); + if (this.source[this.position] !== ":") this.invalid(); + this.position += 1; + result[key] = this.value(); + this.whitespace(); + const separator = this.source[this.position]; + this.position += 1; + if (separator === "}") return result; + if (separator !== ",") this.invalid(); + } + return this.invalid(); + } + + private array(): unknown[] { + this.position += 1; + const result: unknown[] = []; + this.whitespace(); + if (this.source[this.position] === "]") { + this.position += 1; + return result; + } + while (this.position < this.source.length) { + result.push(this.value()); + this.whitespace(); + const separator = this.source[this.position]; + this.position += 1; + if (separator === "]") return result; + if (separator !== ",") this.invalid(); + } + return this.invalid(); + } + + private string(): string { + const start = this.position; + this.position += 1; + while (this.position < this.source.length) { + const character = this.source[this.position]; + if (character === '"') { + this.position += 1; + try { + return JSON.parse(this.source.slice(start, this.position)) as string; + } catch { + return this.invalid(); + } + } + if (character === "\\") { + this.position += 2; + continue; + } + if (!character || character.charCodeAt(0) < 0x20) this.invalid(); + this.position += 1; + } + return this.invalid(); + } + + private number(): number { + const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/u.exec( + this.source.slice(this.position), + ); + if (!match) return this.invalid(); + this.position += match[0].length; + const value = Number(match[0]); + return Number.isFinite(value) ? value : this.invalid(); + } + + private literal(token: string, value: T): T { + if (!this.source.startsWith(token, this.position)) return this.invalid(); + this.position += token.length; + return value; + } + + private whitespace(): void { + while (/\s/u.test(this.source[this.position] ?? "") && this.position < this.source.length) { + const character = this.source[this.position]; + if (character !== " " && character !== "\t" && character !== "\r" && character !== "\n") { + this.invalid(); + } + this.position += 1; + } + } + + private invalid(): never { + throw new ExternalComponentContractError("declaration_invalid"); + } +} + +function fileType(stat: fs.Stats): FileIdentity["type"] | null { + if (stat.isDirectory()) return "directory"; + if (stat.isFile()) return "file"; + if (stat.isSocket()) return "socket"; + return null; +} + +function identity(stat: fs.Stats, expected: FileIdentity["type"]): FileIdentity { + if (stat.isSymbolicLink() || fileType(stat) !== expected) { + throw new ExternalComponentContractError( + expected === "socket" ? "socket_type" : "declaration_ambiguous", + ); + } + return { + dev: stat.dev, + ino: stat.ino, + mode: stat.mode & 0o7777, + uid: stat.uid, + type: expected, + }; +} + +function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.mode === right.mode && + left.uid === right.uid && + left.type === right.type + ); +} + +function assertPathProof(proof: PathProof, code: ExternalComponentContractErrorCode): void { + let current: FileIdentity; + try { + current = identity(fs.lstatSync(proof.path), proof.identity.type); + } catch { + throw new ExternalComponentContractError(code); + } + if (!sameIdentity(proof.identity, current)) throw new ExternalComponentContractError(code); +} + +function declarationParents(homeDirectory: string, declarationPath: string): string[] { + const home = path.resolve(homeDirectory); + const parent = path.dirname(path.resolve(declarationPath)); + const relative = path.relative(home, parent); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new ExternalComponentContractError("declaration_ambiguous"); + } + const result = [home]; + let current = home; + for (const part of relative.split(path.sep).filter(Boolean)) { + current = path.join(current, part); + result.push(current); + } + return result; +} + +function endpointParents(socketPath: string): string[] { + const result: string[] = []; + let current = path.dirname(socketPath); + while (current !== path.dirname(current)) { + result.push(current); + current = path.dirname(current); + } + result.push(current); + return result.reverse(); +} + +function captureSafeParents( + paths: readonly string[], + uid: number, + declaration: boolean, +): PathProof[] { + return paths.map((candidate) => { + let stat: fs.Stats; + try { + stat = fs.lstatSync(candidate); + } catch { + throw new ExternalComponentContractError( + declaration ? "declaration_ambiguous" : "socket_parent_unsafe", + ); + } + const captured = identity(stat, "directory"); + const allowedOwner = declaration + ? captured.uid === uid + : captured.uid === uid || captured.uid === 0; + if (!allowedOwner || (captured.mode & 0o022) !== 0) { + throw new ExternalComponentContractError( + declaration ? "declaration_ambiguous" : "socket_parent_unsafe", + ); + } + return { identity: captured, path: candidate }; + }); +} + +function captureDeclaration( + declarationPath: string, + homeDirectory: string, + uid: number, +): DeclarationProof { + let pathStat: fs.Stats; + try { + pathStat = fs.lstatSync(declarationPath); + } catch (error) { + if (errnoCode(error) === "ELOOP") { + throw new ExternalComponentContractError("declaration_symlink"); + } + throw new ExternalComponentContractError("declaration_ambiguous"); + } + if (pathStat.isSymbolicLink()) { + throw new ExternalComponentContractError("declaration_symlink"); + } + const captured = identity(pathStat, "file"); + if (captured.uid !== uid) throw new ExternalComponentContractError("declaration_owner"); + if (captured.mode !== 0o600) throw new ExternalComponentContractError("declaration_mode"); + const parents = captureSafeParents(declarationParents(homeDirectory, declarationPath), uid, true); + let file: ReturnType; + try { + file = openRegularFileNoFollow(declarationPath); + } catch { + throw new ExternalComponentContractError("declaration_ambiguous"); + } + try { + const descriptorStat = file.stat(); + const descriptorIdentity = identity(descriptorStat, "file"); + if (!sameIdentity(captured, descriptorIdentity)) { + throw new ExternalComponentContractError("declaration_ambiguous"); + } + try { + return { + bytes: file.readBytes(DECLARATION_MAX_BYTES), + identity: captured, + parents, + path: declarationPath, + }; + } catch (error) { + if (error instanceof ExternalComponentContractError) throw error; + throw new ExternalComponentContractError("declaration_invalid"); + } + } finally { + file.close(); + } +} + +function captureEndpoint(socketPath: string, uid: number): EndpointProof { + const parents = captureSafeParents(endpointParents(socketPath), uid, false); + let stat: fs.Stats; + try { + stat = fs.lstatSync(socketPath); + } catch { + throw new ExternalComponentContractError("socket_ambiguous"); + } + const captured = identity(stat, "socket"); + if (captured.uid !== uid) throw new ExternalComponentContractError("socket_owner"); + if ((captured.mode & 0o077) !== 0 || (captured.mode & 0o600) !== 0o600) { + throw new ExternalComponentContractError("socket_mode"); + } + return { identity: captured, parents, path: socketPath }; +} + +function revalidateDeclaration(proof: DeclarationProof): void { + for (const parent of proof.parents) assertPathProof(parent, "declaration_ambiguous"); + assertPathProof(proof, "declaration_ambiguous"); + let file: ReturnType; + try { + file = openRegularFileNoFollow(proof.path); + } catch { + throw new ExternalComponentContractError("declaration_ambiguous"); + } + try { + try { + if (!sameIdentity(proof.identity, identity(file.stat(), "file"))) { + throw new ExternalComponentContractError("declaration_ambiguous"); + } + if (!file.readBytes(DECLARATION_MAX_BYTES).equals(proof.bytes)) { + throw new ExternalComponentContractError("declaration_ambiguous"); + } + } catch (error) { + if (error instanceof ExternalComponentContractError) throw error; + throw new ExternalComponentContractError("declaration_ambiguous"); + } + } finally { + file.close(); + } +} + +function revalidateEndpoint(proof: EndpointProof): void { + for (const parent of proof.parents) assertPathProof(parent, "socket_ambiguous"); + assertPathProof(proof, "socket_ambiguous"); +} + +function validatedSocketPath(value: unknown): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > 4096 || + value.includes("\0") || + !path.isAbsolute(value) || + path.normalize(value) !== value + ) { + throw new ExternalComponentContractError("declaration_invalid"); + } + return value; +} + +export function parseStrictExternalComponentJson(source: string): unknown { + return new StrictJsonParser(source).parse(); +} + +export function parseExternalComponentDeclaration(source: string): ExternalComponentDeclaration { + const parsed = parseStrictExternalComponentJson(source); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new ExternalComponentContractError("declaration_invalid"); + } + const record = parsed as Record; + if (Object.keys(record).some((field) => !DECLARATION_FIELDS.has(field))) { + throw new ExternalComponentContractError("declaration_unknown_field"); + } + if (record.schemaVersion !== EXTERNAL_COMPONENT_SCHEMA_VERSION) { + throw new ExternalComponentContractError("schema_unsupported"); + } + if (typeof record.componentId !== "string" || !COMPONENT_ID_PATTERN.test(record.componentId)) { + throw new ExternalComponentContractError("declaration_invalid"); + } + const interceptorSocketPath = validatedSocketPath(record.interceptorSocketPath); + const activationSocketPath = validatedSocketPath(record.activationSocketPath); + if (interceptorSocketPath === activationSocketPath) { + throw new ExternalComponentContractError("declaration_invalid"); + } + if (Object.keys(record).length !== DECLARATION_FIELDS.size) { + throw new ExternalComponentContractError("declaration_invalid"); + } + return { + schemaVersion: EXTERNAL_COMPONENT_SCHEMA_VERSION, + componentId: record.componentId, + interceptorSocketPath, + activationSocketPath, + }; +} + +export function loadExternalComponentDeclaration( + options: LoadExternalComponentOptions = {}, +): PreparedExternalComponent | null { + const homeDirectory = path.resolve(options.homeDirectory ?? os.homedir()); + const declarationPath = + options.declarationPath ?? + path.join(homeDirectory, ".config", "nemoclaw", EXTERNAL_COMPONENT_DECLARATION_NAME); + let stat: fs.Stats; + try { + stat = fs.lstatSync(declarationPath); + } catch (error) { + if (errnoCode(error) === "ENOENT") return null; + throw new ExternalComponentContractError("declaration_ambiguous"); + } + if (stat.isSymbolicLink()) throw new ExternalComponentContractError("declaration_symlink"); + if ((options.platform ?? process.platform) !== "linux") { + throw new ExternalComponentContractError("platform_unsupported"); + } + const uid = options.uid ?? process.geteuid?.(); + if (uid === undefined) throw new ExternalComponentContractError("declaration_owner"); + const declarationProof = captureDeclaration(declarationPath, homeDirectory, uid); + let source: string; + try { + source = UTF8_DECODER.decode(declarationProof.bytes); + } catch { + throw new ExternalComponentContractError("declaration_invalid"); + } + const declaration = parseExternalComponentDeclaration(source); + const interceptorProof = captureEndpoint(declaration.interceptorSocketPath, uid); + const activationProof = captureEndpoint(declaration.activationSocketPath, uid); + const revalidate = (): void => { + revalidateDeclaration(declarationProof); + revalidateEndpoint(interceptorProof); + revalidateEndpoint(activationProof); + }; + return { + declaration, + revalidateBeforeGateway: revalidate, + revalidateBeforeActivation: revalidate, + }; +} diff --git a/src/lib/onboard/external-component/onboarding.test.ts b/src/lib/onboard/external-component/onboarding.test.ts new file mode 100644 index 00000000000..6a884e7bb26 --- /dev/null +++ b/src/lib/onboard/external-component/onboarding.test.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { ExternalComponentContractError } from "./index"; +import { prepareExternalComponent } from "./onboarding"; + +describe("external component onboarding lifecycle", () => { + it("does not retry an incomplete activation automatically (#11340)", () => { + expect(() => + prepareExternalComponent({ + externalComponentActivation: { + schemaVersion: 1, + resultClass: "ambiguous", + }, + }), + ).toThrowError( + expect.objectContaining>({ + code: "lifecycle_unsupported", + }), + ); + }); +}); diff --git a/src/lib/onboard/external-component/onboarding.ts b/src/lib/onboard/external-component/onboarding.ts new file mode 100644 index 00000000000..8f5959b9257 --- /dev/null +++ b/src/lib/onboard/external-component/onboarding.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { inspectPolicyMutationContext } from "../../policy"; +import { + ExternalComponentContractError, + loadExternalComponentDeclaration, + type PreparedExternalComponent, +} from "./index"; +import { activateExternalComponent, createExternalComponentActivationId } from "./activation"; +import { createExternalComponentActivationProof } from "./proof"; + +export function prepareExternalComponent( + session: { + externalComponentActivation?: unknown; + } | null, +): PreparedExternalComponent | null { + if (session?.externalComponentActivation) { + throw new ExternalComponentContractError("lifecycle_unsupported"); + } + return loadExternalComponentDeclaration(); +} + +export function initialFlowDeps(readiness: { collectGatewayReadiness(): Promise }) { + return { + assertGatewayReadiness: () => readiness.collectGatewayReadiness().then(() => undefined), + prepareExternalComponent, + }; +} + +type ExternalComponentActivationEvidence = { + readonly schemaVersion: 1; + readonly activationId: string; + readonly componentId: string; + readonly lifecycleGeneration: string; + readonly sandboxIdentityFingerprint: string; + readonly resultClass: "failed" | "ambiguous"; +}; + +interface OnboardSessionAccess { + updateSession( + mutator: (session: { + externalComponentActivation: ExternalComponentActivationEvidence | null; + }) => void, + ): unknown; +} + +interface RegistryAccess { + getSandbox(name: string): { + readonly name: string; + readonly gatewayName?: string | null; + readonly gatewayPort?: number | null; + readonly lifecycleGeneration?: string; + readonly lifecycleLiveIdentityFingerprint?: string; + } | null; + setDefault(name: string): void; +} + +type CaptureOpenShell = (args: string[], options?: { ignoreError?: boolean }) => string; + +export function finalDeps( + gatewayName: string, + onboardSession: OnboardSessionAccess, + registry: RegistryAccess, + runCaptureOpenshell: CaptureOpenShell, +) { + return { + createExternalComponentActivationProof: (sandboxName: string) => + createExternalComponentActivationProof(sandboxName, gatewayName, { + getSandbox: registry.getSandbox, + inspectPolicy: inspectPolicyMutationContext, + listSandboxes: (selectedGatewayName: string) => + runCaptureOpenshell(["sandbox", "list", "-g", selectedGatewayName, "--output", "json"], { + ignoreError: false, + }), + }), + createExternalComponentActivationId, + activateExternalComponent: ( + component: PreparedExternalComponent, + proof: import("./activation").ExternalComponentActivationProof, + activationId: string, + ) => activateExternalComponent(component, proof, undefined, activationId), + setExternalComponentActivationEvidence: ( + evidence: ExternalComponentActivationEvidence | null, + ) => { + onboardSession.updateSession((session) => { + session.externalComponentActivation = evidence; + }); + }, + setDefaultSandbox: registry.setDefault, + }; +} diff --git a/src/lib/onboard/external-component/proof.test.ts b/src/lib/onboard/external-component/proof.test.ts new file mode 100644 index 00000000000..fba79038212 --- /dev/null +++ b/src/lib/onboard/external-component/proof.test.ts @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { fingerprintOpenShellSandboxId } from "../../adapters/openshell/sandbox-identity"; +import type { SandboxEntry } from "../../state/registry/types"; +import { createExternalComponentActivationProof, ExternalComponentProofError } from "./proof"; + +const sandboxId = "sandbox-123"; +const policyHash = `sha256:${"a".repeat(64)}`; +const requiredPolicy = + "version: 1\nnetwork_policies:\n inference:\n endpoints:\n - host: example.test\n"; + +function fixture() { + const entry: SandboxEntry = { + name: "assistant", + gatewayName: "nemoclaw", + gatewayPort: 8080, + lifecycleGeneration: "generation-1", + lifecycleLiveIdentityFingerprint: fingerprintOpenShellSandboxId(sandboxId)!, + }; + const row = { + id: sandboxId, + name: "assistant", + labels: {}, + resource_version: 4, + created_at: "2026-09-09T00:00:00Z", + phase: "Ready", + current_policy_version: 3, + }; + const inspection = { + policySource: "sandbox" as const, + effectivePolicy: { + version: 1, + network_policies: { + inference: { endpoints: [{ host: "example.test", port: 443 }] }, + }, + }, + policyIdentity: { hash: policyHash, activeVersion: 3 }, + }; + const deps = { + getSandbox: vi.fn(() => entry as SandboxEntry | null), + listSandboxes: vi.fn(() => JSON.stringify([row])), + inspectPolicy: vi.fn(() => ({ + basePolicyDocument: requiredPolicy, + gatewayName: "nemoclaw", + inspection, + })), + }; + return { deps, entry, inspection, row }; +} + +describe("external component activation proof", () => { + it("binds the durable OpenShell identity to the effective policy (#11340)", () => { + const { deps } = fixture(); + + const proof = createExternalComponentActivationProof("assistant", "nemoclaw", deps); + + expect(proof).toMatchObject({ + gatewayName: "nemoclaw", + sandboxId, + sandboxIdentityFingerprint: `sha256:${fingerprintOpenShellSandboxId(sandboxId)}`, + lifecycleGeneration: "generation-1", + policySource: "sandbox", + policyHash, + policyActiveVersion: 3, + }); + expect(deps.inspectPolicy).toHaveBeenCalledWith( + "assistant", + "verify external component activation policy", + "nemoclaw", + ); + }); + + it("rejects a mutable-name match with a different durable identity (#11340)", () => { + const { deps, row } = fixture(); + deps.listSandboxes.mockReturnValue(JSON.stringify([{ ...row, id: "replacement-456" }])); + + expect(() => createExternalComponentActivationProof("assistant", "nemoclaw", deps)).toThrow( + ExternalComponentProofError, + ); + expect(deps.inspectPolicy).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "missing registry authority", + (value: ReturnType) => value.deps.getSandbox.mockReturnValue(null), + ], + [ + "a different recorded gateway", + (value: ReturnType) => { + value.entry.gatewayName = "other"; + }, + ], + [ + "ambiguous list rows", + (value: ReturnType) => + value.deps.listSandboxes.mockReturnValue(JSON.stringify([value.row, value.row])), + ], + [ + "a policy version mismatch", + (value: ReturnType) => { + value.inspection.policyIdentity.activeVersion = 4; + }, + ], + [ + "an invalid policy hash", + (value: ReturnType) => { + value.inspection.policyIdentity.hash = "opaque"; + }, + ], + [ + "missing required policy", + (value: ReturnType) => { + value.inspection.effectivePolicy.network_policies.inference.endpoints = []; + }, + ], + ])("rejects %s before handoff (#11340)", (_title, mutate) => { + const value = fixture(); + mutate(value); + + expect(() => + createExternalComponentActivationProof("assistant", "nemoclaw", value.deps), + ).toThrow(ExternalComponentProofError); + }); + + it("rejects identity or policy changes during revalidation (#11340)", () => { + const { deps, row } = fixture(); + const proof = createExternalComponentActivationProof("assistant", "nemoclaw", deps); + deps.listSandboxes.mockReturnValue( + JSON.stringify([{ ...row, current_policy_version: row.current_policy_version + 1 }]), + ); + + expect(() => proof.revalidate("after_activation")).toThrow(ExternalComponentProofError); + }); + + it("replaces inspection details with the bounded proof reason class (#11340)", () => { + const { deps } = fixture(); + deps.inspectPolicy.mockImplementation(() => { + throw new Error("component text and /private/activation.sock"); + }); + + expect(() => createExternalComponentActivationProof("assistant", "nemoclaw", deps)).toThrow( + "External component activation proof is unavailable. Reason class: evidence_mismatch.", + ); + }); +}); diff --git a/src/lib/onboard/external-component/proof.ts b/src/lib/onboard/external-component/proof.ts new file mode 100644 index 00000000000..b34f8981299 --- /dev/null +++ b/src/lib/onboard/external-component/proof.ts @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isDeepStrictEqual } from "node:util"; + +import { + assertPolicyRequirementContainment, + parseOpenShellPolicy, + type OpenShellPolicyInspection, +} from "../../adapters/openshell/policy-boundary"; +import { + fingerprintOpenShellSandboxId, + parseStrictOpenShellSandboxListJson, +} from "../../adapters/openshell/sandbox-identity"; +import type { ExternalComponentActivationProof } from "./activation"; + +export class ExternalComponentProofError extends Error { + constructor() { + super("External component activation proof is unavailable. Reason class: evidence_mismatch."); + this.name = "ExternalComponentProofError"; + } +} + +interface PolicyContext { + readonly basePolicyDocument: string; + readonly gatewayName: string; + readonly inspection: OpenShellPolicyInspection; +} + +interface ExternalComponentProofDeps { + getSandbox(name: string): { + readonly name: string; + readonly gatewayName?: string | null; + readonly gatewayPort?: number | null; + readonly lifecycleGeneration?: string; + readonly lifecycleLiveIdentityFingerprint?: string; + } | null; + inspectPolicy(name: string, operation: string, gatewayName: string): PolicyContext; + listSandboxes(gatewayName: string): string; +} + +interface ProofSnapshot { + readonly gatewayName: string; + readonly lifecycleGeneration: string; + readonly policyActiveVersion: number; + readonly policyHash: string; + readonly policySource: "sandbox" | "global"; + readonly sandboxId: string; + readonly sandboxIdentityFingerprint: string; +} + +function captureProofSnapshotUnchecked( + sandboxName: string, + expectedGatewayName: string, + deps: ExternalComponentProofDeps, +): ProofSnapshot { + const entry = deps.getSandbox(sandboxName); + if ( + !entry || + entry.name !== sandboxName || + entry.gatewayName !== expectedGatewayName || + typeof entry.gatewayPort !== "number" || + !Number.isSafeInteger(entry.gatewayPort) || + typeof entry.lifecycleGeneration !== "string" || + entry.lifecycleGeneration.length === 0 || + typeof entry.lifecycleLiveIdentityFingerprint !== "string" || + !/^[0-9a-f]{64}$/u.test(entry.lifecycleLiveIdentityFingerprint) + ) { + throw new ExternalComponentProofError(); + } + const rows = parseStrictOpenShellSandboxListJson(deps.listSandboxes(expectedGatewayName)); + const matches = rows?.filter((row) => row.name === sandboxName) ?? []; + if (matches.length !== 1) throw new ExternalComponentProofError(); + const row = matches[0]!; + const fingerprint = fingerprintOpenShellSandboxId(row.id); + if (fingerprint !== entry.lifecycleLiveIdentityFingerprint) { + throw new ExternalComponentProofError(); + } + const policy = deps.inspectPolicy( + sandboxName, + "verify external component activation policy", + expectedGatewayName, + ); + if ( + policy.gatewayName !== expectedGatewayName || + row.current_policy_version !== policy.inspection.policyIdentity.activeVersion || + !/^sha256:[0-9a-f]{64}$/u.test(policy.inspection.policyIdentity.hash) + ) { + throw new ExternalComponentProofError(); + } + try { + assertPolicyRequirementContainment( + policy.inspection, + parseOpenShellPolicy(policy.basePolicyDocument).policy, + ); + } catch { + throw new ExternalComponentProofError(); + } + return { + gatewayName: expectedGatewayName, + lifecycleGeneration: entry.lifecycleGeneration, + policyActiveVersion: policy.inspection.policyIdentity.activeVersion, + policyHash: policy.inspection.policyIdentity.hash, + policySource: policy.inspection.policySource, + sandboxId: row.id, + sandboxIdentityFingerprint: `sha256:${fingerprint}`, + }; +} + +function captureProofSnapshot( + sandboxName: string, + expectedGatewayName: string, + deps: ExternalComponentProofDeps, +): ProofSnapshot { + try { + return captureProofSnapshotUnchecked(sandboxName, expectedGatewayName, deps); + } catch { + throw new ExternalComponentProofError(); + } +} + +export function createExternalComponentActivationProof( + sandboxName: string, + gatewayName: string, + deps: ExternalComponentProofDeps, +): ExternalComponentActivationProof { + const initial = captureProofSnapshot(sandboxName, gatewayName, deps); + return { + ...initial, + revalidate: () => { + if (!isDeepStrictEqual(captureProofSnapshot(sandboxName, gatewayName, deps), initial)) { + throw new ExternalComponentProofError(); + } + }, + }; +} diff --git a/src/lib/onboard/gateway-reuse.ts b/src/lib/onboard/gateway-reuse.ts index 8ba82bd07cd..8d351becbf7 100644 --- a/src/lib/onboard/gateway-reuse.ts +++ b/src/lib/onboard/gateway-reuse.ts @@ -12,6 +12,7 @@ import { shouldSelectNamedGatewayForReuse, } from "../state/gateway"; import * as dockerDriverGatewayLaunch from "./docker-driver-gateway-launch"; +import type { ExternalComponentGatewayConfiguration } from "./docker-driver-gateway-config"; import { configuredRuntimeProviderOwnsHostReadiness } from "./docker-driver-gateway-env"; import * as gatewayService from "./docker-driver-gateway-service"; import type { PortProbeResult } from "./preflight"; @@ -43,7 +44,11 @@ export interface DockerDriverGatewayReuseApplicationDeps { getGatewayCompatContainerName(): string; isDockerDriverGatewayEnabled(): boolean; resolveOpenShellGatewayBinary(): string | null; - getDockerDriverGatewayEnv(versionOutput?: string | null): Record; + getDockerDriverGatewayEnv( + versionOutput?: string | null, + platform?: NodeJS.Platform, + externalComponent?: ExternalComponentGatewayConfiguration | null, + ): Record; runCaptureOpenshell(args: string[], opts?: { ignoreError?: boolean }): string; getDockerDriverGatewayStateDir(): string; resolveOpenShellSandboxBinary(): string | null; @@ -87,7 +92,10 @@ export type DockerDriverNetworkInspectRunner = ( ) => DockerDriverNetworkInspectCommandResult; export interface DockerDriverGatewayReuseApplication { - refreshDockerDriverGatewayReuseState(state: GatewayReuseState): Promise; + refreshDockerDriverGatewayReuseState( + state: GatewayReuseState, + externalComponent?: ExternalComponentGatewayConfiguration | null, + ): Promise; } function outputText(value: unknown): string { @@ -144,6 +152,7 @@ export function createDockerDriverGatewayReuseApplication( async function refreshDockerDriverGatewayReuseState( state: GatewayReuseState, + externalComponent?: ExternalComponentGatewayConfiguration | null, ): Promise { if (!deps.isDockerDriverGatewayEnabled() || state !== "healthy") return state; if (configuredRuntimeProviderOwnsHostReadiness()) return state; @@ -151,6 +160,8 @@ export function createDockerDriverGatewayReuseApplication( const gatewayBin = deps.resolveOpenShellGatewayBinary(); const baseDesiredEnv = deps.getDockerDriverGatewayEnv( deps.runCaptureOpenshell(["--version"], { ignoreError: true }), + undefined, + externalComponent, ); const runtimeIdentity = gatewayBin ? buildRuntimeIdentity({ diff --git a/src/lib/onboard/gateway/docker-driver-start.ts b/src/lib/onboard/gateway/docker-driver-start.ts index cf15c928cd5..fd666750bac 100644 --- a/src/lib/onboard/gateway/docker-driver-start.ts +++ b/src/lib/onboard/gateway/docker-driver-start.ts @@ -22,6 +22,7 @@ import * as dockerDriverGatewayRuntimeMarker from "../docker-driver-gateway-runt import * as gatewayStateLifecycleLock from "./state-lifecycle-lock"; import { formatGatewayHealthWaitLimit } from "../gateway-health-wait"; import { verifySandboxBridgeGatewayReachableOrExit } from "../gateway-sandbox-reachability"; +import type { ExternalComponentGatewayConfiguration } from "../docker-driver-gateway-config"; type GatewayRuntimeHelpers = ReturnType< typeof import("../docker-driver-gateway-runtime").createDockerDriverGatewayRuntimeHelpers @@ -73,6 +74,7 @@ export interface DockerDriverGatewayStartDeps { export interface DockerDriverGatewayStart { startDockerDriverGateway(options?: { exitOnFailure?: boolean; + externalComponent?: ExternalComponentGatewayConfiguration | null; runtimeSelection?: OpenShellRuntimeSelection; skipSandboxBridgeReachability?: boolean; }): Promise; @@ -90,10 +92,12 @@ export function createDockerDriverGatewayStart( ): DockerDriverGatewayStart { async function startDockerDriverGateway({ exitOnFailure = true, + externalComponent, runtimeSelection, skipSandboxBridgeReachability = false, }: { exitOnFailure?: boolean; + externalComponent?: ExternalComponentGatewayConfiguration | null; runtimeSelection?: OpenShellRuntimeSelection; skipSandboxBridgeReachability?: boolean; } = {}): Promise { @@ -144,7 +148,11 @@ export function createDockerDriverGatewayStart( } const gatewayBin = deps.resolveOpenShellGatewayBinary(); const openshellVersionOutput = runCaptureOpenshell(["--version"], { ignoreError: true }); - const gatewayEnv = deps.getDockerDriverGatewayEnv(openshellVersionOutput); + const gatewayEnv = deps.getDockerDriverGatewayEnv( + openshellVersionOutput, + undefined, + externalComponent, + ); const runtimeIdentity = gatewayBin ? dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity({ gatewayBin, diff --git a/src/lib/onboard/gateway/start.ts b/src/lib/onboard/gateway/start.ts index c3ca3c24bf0..cea949b7bca 100644 --- a/src/lib/onboard/gateway/start.ts +++ b/src/lib/onboard/gateway/start.ts @@ -6,6 +6,7 @@ import { withSelectedOpenShellCommandOptions, } from "../../adapters/openshell/command-argv"; import { gatewayStartGuidance } from "../../gateway-start-guidance"; +import type { ExternalComponentGatewayConfiguration } from "../docker-driver-gateway-config"; import { normalizeGatewayStartError } from "../gateway-start-failure"; type OnboardGpu = ReturnType; @@ -38,6 +39,7 @@ export interface GatewayStartDeps { selectNamedGatewayForReuseIfNeeded: GatewayReuseHelpers["selectNamedGatewayForReuseIfNeeded"]; startDockerDriverGateway(options?: { exitOnFailure?: boolean; + externalComponent?: ExternalComponentGatewayConfiguration | null; runtimeSelection?: OpenShellRuntimeSelection; skipSandboxBridgeReachability?: boolean; }): Promise; @@ -45,11 +47,18 @@ export interface GatewayStartDeps { } export interface GatewayStart { - startGateway(gpu: OnboardGpu, options?: { gpuPassthrough?: boolean }): Promise; + startGateway( + gpu: OnboardGpu, + options?: { + externalComponent?: ExternalComponentGatewayConfiguration | null; + gpuPassthrough?: boolean; + }, + ): Promise; startGatewayWithOptions( gpu: OnboardGpu, options?: { exitOnFailure?: boolean; + externalComponent?: ExternalComponentGatewayConfiguration | null; gpuPassthrough?: boolean; runtimeSelection?: OpenShellRuntimeSelection; }, @@ -61,10 +70,12 @@ export function createGatewayStart(deps: GatewayStartDeps): GatewayStart { gpu: OnboardGpu, { exitOnFailure = true, + externalComponent, gpuPassthrough = false, runtimeSelection, }: { exitOnFailure?: boolean; + externalComponent?: ExternalComponentGatewayConfiguration | null; gpuPassthrough?: boolean; runtimeSelection?: OpenShellRuntimeSelection; } = {}, @@ -83,6 +94,7 @@ export function createGatewayStart(deps: GatewayStartDeps): GatewayStart { ); return deps.startDockerDriverGateway({ exitOnFailure, + externalComponent, ...(runtimeSelection ? { runtimeSelection } : {}), skipSandboxBridgeReachability: deps.dockerGpuLocalInference.shouldSkipGpuBridgeProbe( gpuPassthrough, @@ -126,9 +138,19 @@ export function createGatewayStart(deps: GatewayStartDeps): GatewayStart { async function startGateway( gpu: OnboardGpu, - { gpuPassthrough = false }: { gpuPassthrough?: boolean } = {}, + { + externalComponent, + gpuPassthrough = false, + }: { + externalComponent?: ExternalComponentGatewayConfiguration | null; + gpuPassthrough?: boolean; + } = {}, ): Promise { - return startGatewayWithOptions(gpu, { exitOnFailure: true, gpuPassthrough }); + return startGatewayWithOptions(gpu, { + exitOnFailure: true, + externalComponent, + gpuPassthrough, + }); } return { startGateway, startGatewayWithOptions }; diff --git a/src/lib/onboard/machine/core-flow-phases.ts b/src/lib/onboard/machine/core-flow-phases.ts index 928f26d40ee..37ee9503384 100644 --- a/src/lib/onboard/machine/core-flow-phases.ts +++ b/src/lib/onboard/machine/core-flow-phases.ts @@ -333,6 +333,8 @@ export function createSandboxOnboardFlowPhase< gatewayName: options.gatewayName, hermesPortableLifecycle: options.hermesPortableLifecycle === true, apfInterceptorRequested: options.apfInterceptorRequested === true, + externalComponentRegistered: + context.externalComponent !== null && context.externalComponent !== undefined, authoritativeResumeConfig: options.authoritativeResumeConfig, deferredN1xManagedVllmPreviewIntent: context.deferredN1xManagedVllmPreviewAccepted === true && diff --git a/src/lib/onboard/machine/final-flow-phases.ts b/src/lib/onboard/machine/final-flow-phases.ts index 9fdf25b6bbd..d5c35c50e8e 100644 --- a/src/lib/onboard/machine/final-flow-phases.ts +++ b/src/lib/onboard/machine/final-flow-phases.ts @@ -132,6 +132,7 @@ export function createFinalOnboardFlowPhases< : null, portableProfileSelected: context.session?.checkpoint?.profile.value === "portable", recreateJournalHandoff: context.recreateJournalHandoff, + externalComponent: context.externalComponent, deps: finalizationDeps, }); return { result: finalizationResult.stateResult }; @@ -157,6 +158,7 @@ export function createFinalOnboardFlowPhases< : null, portableProfileSelected: context.session?.checkpoint?.profile.value === "portable", recreateJournalHandoff: context.recreateJournalHandoff, + externalComponent: null, deps: finalizationDeps, }); return { result: postVerifyResult.stateResult }; diff --git a/src/lib/onboard/machine/flow-context.ts b/src/lib/onboard/machine/flow-context.ts index aba94d67c33..c64fbdf43e3 100644 --- a/src/lib/onboard/machine/flow-context.ts +++ b/src/lib/onboard/machine/flow-context.ts @@ -5,6 +5,7 @@ import type { InferenceEndpointSource } from "../../inference/selection"; import type { WebSearchConfig } from "../../inference/web-search"; import type { Session } from "../../state/onboard-session"; import type { HostLocalInferenceSandboxProofAuthority } from "../runtime-provider/host-local-inference-routing"; +import type { PreparedExternalComponent } from "../external-component"; import type { OnboardStateHandlerResult } from "./runner"; export interface OnboardFlowContext { @@ -44,6 +45,8 @@ export interface OnboardFlowContext = diff --git a/src/lib/onboard/machine/handlers/finalization.test.ts b/src/lib/onboard/machine/handlers/finalization.test.ts index 41a1b9b68ed..c7ebc95675e 100644 --- a/src/lib/onboard/machine/handlers/finalization.test.ts +++ b/src/lib/onboard/machine/handlers/finalization.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it, vi } from "vitest"; import type { SessionUpdates } from "../../../state/onboard-session"; +import type { PreparedExternalComponent } from "../../external-component"; +import type { ExternalComponentActivationProof } from "../../external-component/activation"; import { type FinalizationStateOptions, handleFinalizationState as handleFinalizationPhase, @@ -24,6 +26,35 @@ type Agent = { type VerifyChain = { port: number }; type VerificationResult = { ok: boolean }; +const sandboxIdentityFingerprint = `sha256:${"b".repeat(64)}`; +const externalComponent: PreparedExternalComponent = { + declaration: { + schemaVersion: 1, + componentId: "policy-governance", + interceptorSocketPath: "/run/user/1000/component/interceptor.sock", + activationSocketPath: "/run/user/1000/component/activation.sock", + }, + revalidateBeforeGateway: vi.fn(), + revalidateBeforeActivation: vi.fn(), +}; +const activationProof: ExternalComponentActivationProof = { + gatewayName: "nemoclaw", + sandboxId: "sandbox-123", + sandboxIdentityFingerprint, + lifecycleGeneration: "generation-1", + policySource: "sandbox", + policyHash: `sha256:${"a".repeat(64)}`, + policyActiveVersion: 7, + revalidate: vi.fn(), +}; +const activationEvidence = { + schemaVersion: 1 as const, + activationId: "4b5a8e18-f967-4e27-a3b2-f2cc315abe21", + componentId: "policy-governance", + lifecycleGeneration: "generation-1", + sandboxIdentityFingerprint, +}; + function createDeps( overrides: Partial["deps"]> = {}, ) { @@ -49,6 +80,10 @@ function createDeps( dashboard: vi.fn(), isHealthy: vi.fn(() => true), reportReadiness: vi.fn(), + createExternalComponentActivationProof: vi.fn(() => activationProof), + createExternalComponentActivationId: vi.fn(() => "4b5a8e18-f967-4e27-a3b2-f2cc315abe21"), + activateExternalComponent: vi.fn(async () => ({ kind: "activated" as const })), + setExternalComponentActivationEvidence: vi.fn(), error: vi.fn(), log: vi.fn(), }; @@ -56,6 +91,10 @@ function createDeps( calls, deps: { setDefaultSandbox: calls.setDefaultSandbox, + createExternalComponentActivationProof: calls.createExternalComponentActivationProof, + createExternalComponentActivationId: calls.createExternalComponentActivationId, + activateExternalComponent: calls.activateExternalComponent, + setExternalComponentActivationEvidence: calls.setExternalComponentActivationEvidence, toSessionUpdates: (updates: Record) => updates as SessionUpdates, removeLegacyCredentialsFile: calls.removeLegacy, cleanupStaleHostFiles: calls.cleanupHost, @@ -111,6 +150,96 @@ async function runFinalizationHandlers( } describe("finalization handlers", () => { + it("activates the registered component before declaring the sandbox ready (#11340)", async () => { + const { deps, calls } = createDeps(); + + const result = await handleFinalizationPhase({ + ...baseOptions(deps), + externalComponent, + }); + + expect(calls.createExternalComponentActivationProof).toHaveBeenCalledWith("my-assistant"); + expect(calls.activateExternalComponent).toHaveBeenCalledWith( + externalComponent, + activationProof, + "4b5a8e18-f967-4e27-a3b2-f2cc315abe21", + ); + expect(calls.setExternalComponentActivationEvidence).toHaveBeenNthCalledWith(1, { + ...activationEvidence, + resultClass: "ambiguous", + }); + expect(calls.setExternalComponentActivationEvidence).toHaveBeenNthCalledWith(2, null); + expect(calls.setExternalComponentActivationEvidence.mock.invocationCallOrder[0]).toBeLessThan( + calls.activateExternalComponent.mock.invocationCallOrder[0], + ); + expect(calls.activateExternalComponent.mock.invocationCallOrder[0]).toBeLessThan( + calls.setDefaultSandbox.mock.invocationCallOrder[0], + ); + expect(result.stateResult).toMatchObject({ + type: "transition", + next: "post_verify", + }); + }); + + it.each([ + ["rejected", "failed"], + ["ambiguous", "ambiguous"], + ] as const)( + "preserves identity-bound incomplete state for %s activation (#11340)", + async (kind, resultClass) => { + const activationId = "4b5a8e18-f967-4e27-a3b2-f2cc315abe21"; + const activate = vi.fn(async () => + kind === "rejected" + ? ({ kind, activationId } as const) + : ({ kind, activationId, reason: "timeout" } as const), + ); + const { deps, calls } = createDeps({ activateExternalComponent: activate }); + + const result = await handleFinalizationPhase({ + ...baseOptions(deps), + externalComponent, + }); + + expect(result.stateResult).toEqual({ + type: "pause", + updates: { + externalComponentActivation: { + schemaVersion: 1, + activationId, + componentId: "policy-governance", + lifecycleGeneration: "generation-1", + sandboxIdentityFingerprint, + resultClass, + }, + }, + metadata: { + state: "finalizing", + reason: "external_component_activation_incomplete", + }, + }); + expect(result.stateResult.updates?.externalComponentActivation).not.toHaveProperty( + "sandboxName", + ); + expect(calls.setDefaultSandbox).not.toHaveBeenCalled(); + expect(calls.removeLegacy).not.toHaveBeenCalled(); + expect(calls.cleanupHost).not.toHaveBeenCalled(); + expect(calls.error).toHaveBeenCalledWith( + ` External component activation is incomplete. Reason class: ${resultClass}. The sandbox was preserved.`, + ); + expect(JSON.stringify(calls.error.mock.calls)).not.toMatch( + /policy-governance|activation\.sock|credential|secret|token|password|api.?key/iu, + ); + expect(calls.setExternalComponentActivationEvidence).toHaveBeenNthCalledWith(1, { + ...activationEvidence, + resultClass: "ambiguous", + }); + expect(calls.setExternalComponentActivationEvidence).toHaveBeenLastCalledWith({ + ...activationEvidence, + resultClass, + }); + }, + ); + it("advances to post verification before deployment verification runs", async () => { const { deps, calls } = createDeps(); diff --git a/src/lib/onboard/machine/handlers/finalization.ts b/src/lib/onboard/machine/handlers/finalization.ts index ba248c6731c..8d47752b0a3 100644 --- a/src/lib/onboard/machine/handlers/finalization.ts +++ b/src/lib/onboard/machine/handlers/finalization.ts @@ -2,7 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 import { CLI_NAME } from "../../../cli/branding"; +import type { ExternalComponentActivationIncomplete } from "../../../state/onboard-session"; import { type DashboardRuntimeAgent, shouldManageDashboardForAgent } from "../../dashboard-runtime"; +import type { PreparedExternalComponent } from "../../external-component"; +import type { + ExternalComponentActivationProof, + ExternalComponentActivationResult, +} from "../../external-component/activation"; import type { WebSearchVerifyProvider } from "../../web-search-verify"; import type { PortableOpenClawPairingSettlementResult } from "../../../actions/sandbox/launch-readiness"; import type { OrdinaryOpenClawPairingSettlementResult } from "../finalization-deps"; @@ -29,6 +35,7 @@ export interface FinalizationStateOptions; + setExternalComponentActivationEvidence?( + evidence: ExternalComponentActivationIncomplete | null, + ): void; toSessionUpdates( updates: Record, ): NonNullable; @@ -99,7 +116,7 @@ export interface FinalizationStateOptions): Promise { const manageDashboard = shouldManageDashboardForAgent(agent as DashboardRuntimeAgent); + if (externalComponent) { + if ( + !deps.createExternalComponentActivationProof || + !deps.createExternalComponentActivationId || + !deps.activateExternalComponent || + !deps.setExternalComponentActivationEvidence + ) { + throw new Error("External component activation is unavailable."); + } + const proof = deps.createExternalComponentActivationProof(sandboxName); + const activationId = deps.createExternalComponentActivationId(); + const evidence = (resultClass: "failed" | "ambiguous") => ({ + schemaVersion: 1 as const, + activationId, + componentId: externalComponent.declaration.componentId, + lifecycleGeneration: proof.lifecycleGeneration, + sandboxIdentityFingerprint: proof.sandboxIdentityFingerprint, + resultClass, + }); + deps.setExternalComponentActivationEvidence(evidence("ambiguous")); + const activation = await deps.activateExternalComponent(externalComponent, proof, activationId); + if (activation.kind !== "activated") { + const resultClass = activation.kind === "rejected" ? "failed" : "ambiguous"; + const incompleteEvidence = evidence(resultClass); + deps.setExternalComponentActivationEvidence(incompleteEvidence); + deps.error( + ` External component activation is incomplete. Reason class: ${resultClass}. The sandbox was preserved.`, + ); + return { + stateResult: pauseOnboardMachine( + deps.toSessionUpdates({ + externalComponentActivation: incompleteEvidence, + }), + { state: "finalizing", reason: "external_component_activation_incomplete" }, + ), + unmigratedLegacyKeys: stagedLegacyKeys.filter((key) => !migratedLegacyKeys.has(key)), + }; + } + deps.setExternalComponentActivationEvidence(null); + } // Reaching finalization means the policy-preset step was confirmed, so it is // now safe to register this sandbox as the default (#4614). deps.setDefaultSandbox(sandboxName); diff --git a/src/lib/onboard/machine/handlers/gateway.test.ts b/src/lib/onboard/machine/handlers/gateway.test.ts index 445c4c0a0ac..b5d88b30d41 100644 --- a/src/lib/onboard/machine/handlers/gateway.test.ts +++ b/src/lib/onboard/machine/handlers/gateway.test.ts @@ -9,6 +9,7 @@ import type { GatewayReuseState } from "../../../state/gateway"; import { createSession, type Session } from "../../../state/onboard-session"; import { flushTrace, resetTraceForTests, TRACE_FILE_ENV, type TraceArtifact } from "../../../trace"; import type { GatewayContainerState } from "../../gateway-container-running"; +import type { PreparedExternalComponent } from "../../external-component"; import { type GatewayAttachmentProbe, type GatewayOwner, @@ -39,6 +40,19 @@ const EXTERNAL_OWNER: GatewayOwner = resolveGatewayOwner({ type Gpu = { type: string } | null; +function preparedExternalComponent(revalidateBeforeGateway = vi.fn()): PreparedExternalComponent { + return { + declaration: { + schemaVersion: 1, + componentId: "policy-governance", + interceptorSocketPath: "/run/user/1000/component/interceptor.sock", + activationSocketPath: "/run/user/1000/component/activation.sock", + }, + revalidateBeforeGateway, + revalidateBeforeActivation: vi.fn(), + }; +} + function createDeps(overrides: Partial["deps"]> = {}) { const calls = { refresh: vi.fn(async (state: GatewayReuseState) => state), @@ -64,29 +78,26 @@ function createDeps(overrides: Partial["deps"]> = {}) { exit: vi.fn((code: number): never => { throw new Error(`exit ${code}`); }), - resolveOwner: vi.fn( - (): GatewayOwner => - resolveGatewayOwner({ - gatewayName: "nemoclaw", - gatewayPort: 8080, - declaration: null, - hasPackagedService: false, - }), - ), - attachGateway: vi.fn(async () => undefined), - probeAttachment: vi.fn( - async (): Promise => ({ + resolveOwner: vi.fn((): GatewayOwner => + resolveGatewayOwner({ + gatewayName: "nemoclaw", gatewayPort: 8080, - httpReady: true, - portOccupied: true, - listenerPids: [4242], - listenerScanComplete: true, - listenerStartTime: "710024", - supervisorActive: true, - listenerExecPath: "/usr/local/bin/openshell-gateway", - listenerSupervisorMatch: true, + declaration: null, + hasPackagedService: false, }), ), + attachGateway: vi.fn(async () => undefined), + probeAttachment: vi.fn(async (): Promise => ({ + gatewayPort: 8080, + httpReady: true, + portOccupied: true, + listenerPids: [4242], + listenerScanComplete: true, + listenerStartTime: "710024", + supervisorActive: true, + listenerExecPath: "/usr/local/bin/openshell-gateway", + listenerSupervisorMatch: true, + })), }; return { calls, @@ -167,13 +178,77 @@ function gatewaySpans(artifact: TraceArtifact) { } describe("handleGatewayState", () => { + it("validates the component before gateway configuration or lifecycle effects (#11340)", async () => { + const revalidateBeforeGateway = vi.fn(() => { + throw new Error("declaration changed"); + }); + const { deps, calls } = createDeps({ + isLinuxDockerDriverGatewayEnabled: vi.fn(() => true), + }); + + await expect( + handleGatewayState({ + ...baseOptions(deps, "missing"), + externalComponent: preparedExternalComponent(revalidateBeforeGateway), + }), + ).rejects.toThrow("declaration changed"); + + expect(revalidateBeforeGateway).toHaveBeenCalledOnce(); + expect(calls.refresh).not.toHaveBeenCalled(); + expect(calls.startStep).not.toHaveBeenCalled(); + expect(calls.retireLegacy).not.toHaveBeenCalled(); + expect(calls.startGateway).not.toHaveBeenCalled(); + }); + + it("passes only the validated component projection to the managed gateway (#11340)", async () => { + const component = preparedExternalComponent(); + const { deps, calls } = createDeps({ + isLinuxDockerDriverGatewayEnabled: vi.fn(() => true), + }); + + await handleGatewayState({ + ...baseOptions(deps, "missing"), + externalComponent: component, + }); + + const projection = { + componentId: "policy-governance", + interceptorSocketPath: "/run/user/1000/component/interceptor.sock", + }; + expect(component.revalidateBeforeGateway).toHaveBeenCalledOnce(); + expect(calls.refresh).toHaveBeenCalledWith("missing", projection); + expect(calls.startGateway).toHaveBeenCalledWith( + { type: "nvidia" }, + { externalComponent: projection, gpuPassthrough: true }, + ); + }); + + it("rejects a registered component outside the supported Linux gateway path (#11340)", async () => { + const component = preparedExternalComponent(); + const { deps, calls } = createDeps(); + + await expect( + handleGatewayState({ + ...baseOptions(deps, "missing"), + externalComponent: component, + }), + ).rejects.toMatchObject({ code: "capability_unsupported" }); + + expect(component.revalidateBeforeGateway).not.toHaveBeenCalled(); + expect(calls.refresh).not.toHaveBeenCalled(); + expect(calls.startGateway).not.toHaveBeenCalled(); + }); + it("starts the gateway when no reusable gateway exists", async () => { const { deps, calls } = createDeps(); const result = await handleGatewayState(baseOptions(deps, "missing")); expect(calls.startStep).toHaveBeenCalledWith("gateway"); - expect(calls.startGateway).toHaveBeenCalledWith({ type: "nvidia" }, { gpuPassthrough: true }); + expect(calls.startGateway).toHaveBeenCalledWith( + { type: "nvidia" }, + { externalComponent: null, gpuPassthrough: true }, + ); expect(calls.complete).toHaveBeenCalledWith("gateway"); expect(result.gatewayReuseState).toBe("missing"); expect(result.stateResult).toEqual({ @@ -222,7 +297,10 @@ describe("handleGatewayState", () => { expect(calls.skipped).not.toHaveBeenCalled(); expect(calls.recordSkip).not.toHaveBeenCalled(); expect(calls.startStep).toHaveBeenCalledWith("gateway"); - expect(calls.startGateway).toHaveBeenCalledWith({ type: "nvidia" }, { gpuPassthrough: true }); + expect(calls.startGateway).toHaveBeenCalledWith( + { type: "nvidia" }, + { externalComponent: null, gpuPassthrough: true }, + ); expect(calls.retireLegacy).not.toHaveBeenCalled(); expect(result.gatewayReuseState).toBe("stale"); }); @@ -664,6 +742,21 @@ describe("externally supervised gateway lifecycle authority", () => { }); }); + it("rejects a registered component before any supervised gateway effect (#11340)", async () => { + const { calls, deps } = externalDeps(); + + await expect( + handleGatewayState({ + ...baseOptions(deps, "missing"), + externalComponent: preparedExternalComponent(), + }), + ).rejects.toMatchObject({ code: "capability_unsupported" }); + + expect(calls.probeAttachment).not.toHaveBeenCalled(); + expect(calls.attachGateway).not.toHaveBeenCalled(); + expect(calls.startGateway).not.toHaveBeenCalled(); + }); + it("rejects host mounts before any externally supervised gateway effect", async () => { const { calls, deps } = externalDeps(); diff --git a/src/lib/onboard/machine/handlers/gateway.ts b/src/lib/onboard/machine/handlers/gateway.ts index 918fcd65a37..db364b8ce7e 100644 --- a/src/lib/onboard/machine/handlers/gateway.ts +++ b/src/lib/onboard/machine/handlers/gateway.ts @@ -5,6 +5,7 @@ import type { NvidiaPlatform } from "../../../inference/nim"; import type { GatewayReuseState } from "../../../state/gateway"; import type { Session } from "../../../state/onboard-session"; import type { GatewayContainerState } from "../../gateway-container-running"; +import type { PreparedExternalComponent } from "../../external-component"; import { describeGatewayOwner, evaluateGatewayAttachment, @@ -27,6 +28,7 @@ export interface GatewayStateOptions { requestedSandboxName: string | null; recreateSandbox: boolean; requiresBindMounts?: boolean; + externalComponent?: PreparedExternalComponent | null; deps: { /** * The single declared lifecycle authority for this run (#6576). Resolved @@ -36,7 +38,13 @@ export interface GatewayStateOptions { resolveGatewayOwner(): GatewayOwner; probeGatewayAttachment(owner: GatewayOwner): Promise; attachGateway(owner: GatewayOwner, expectedProbe: GatewayAttachmentProbe): Promise; - refreshDockerDriverGatewayReuseState(state: GatewayReuseState): Promise; + refreshDockerDriverGatewayReuseState( + state: GatewayReuseState, + externalComponent?: { + readonly componentId: string; + readonly interceptorSocketPath: string; + } | null, + ): Promise; gatewayCliSupportsLifecycleCommands(): boolean; verifyGatewayContainerRunning(gatewayName: string): GatewayContainerState; waitForGatewayHttpReady(): Promise; @@ -76,7 +84,16 @@ export interface GatewayStateOptions { ): Promise; note(message: string): void; startRecordedStep(stepName: string): Promise; - startGateway(gpu: Gpu, options: { gpuPassthrough: boolean }): Promise; + startGateway( + gpu: Gpu, + options: { + externalComponent?: { + readonly componentId: string; + readonly interceptorSocketPath: string; + } | null; + gpuPassthrough: boolean; + }, + ): Promise; recordStepComplete(stepName: string): Promise; exitProcess(code: number): never; }; @@ -107,6 +124,7 @@ async function handleGatewayStatePhase({ requestedSandboxName, recreateSandbox, requiresBindMounts = false, + externalComponent = null, deps, }: GatewayStateOptions): Promise { // Establish the lifecycle authority before anything in this phase can touch @@ -115,6 +133,13 @@ async function handleGatewayStatePhase({ // the port. const owner = deps.resolveGatewayOwner(); if (isExternallySupervised(owner)) { + if (externalComponent) { + throw new GatewayOwnershipError( + "capability_unsupported", + "External component onboarding requires a NemoClaw-managed OpenShell gateway.", + owner, + ); + } if (requiresBindMounts) { throw new GatewayOwnershipError( "capability_unsupported", @@ -125,7 +150,25 @@ async function handleGatewayStatePhase({ return attachToExternallySupervisedGateway(owner, deps); } - let gatewayReuseState = await deps.refreshDockerDriverGatewayReuseState(initialGatewayReuseState); + if (externalComponent && !deps.isLinuxDockerDriverGatewayEnabled()) { + throw new GatewayOwnershipError( + "capability_unsupported", + "External component onboarding requires the supported Linux Docker-driver gateway.", + owner, + ); + } + + externalComponent?.revalidateBeforeGateway(); + + let gatewayReuseState = await deps.refreshDockerDriverGatewayReuseState( + initialGatewayReuseState, + externalComponent + ? { + componentId: externalComponent.declaration.componentId, + interceptorSocketPath: externalComponent.declaration.interceptorSocketPath, + } + : null, + ); const supportsLifecycleCommands = deps.gatewayCliSupportsLifecycleCommands(); if (gatewayReuseState === "healthy" && supportsLifecycleCommands) { @@ -265,7 +308,15 @@ async function handleGatewayStatePhase({ } else if (gatewayReuseState === "foreign-active") { gatewayReuseState = "missing"; } - await deps.startGateway(gpu, { gpuPassthrough }); + await deps.startGateway(gpu, { + externalComponent: externalComponent + ? { + componentId: externalComponent.declaration.componentId, + interceptorSocketPath: externalComponent.declaration.interceptorSocketPath, + } + : null, + gpuPassthrough, + }); session = await deps.recordStepComplete("gateway"); } diff --git a/src/lib/onboard/machine/handlers/sandbox-external-component.test.ts b/src/lib/onboard/machine/handlers/sandbox-external-component.test.ts new file mode 100644 index 00000000000..caf3e6bb911 --- /dev/null +++ b/src/lib/onboard/machine/handlers/sandbox-external-component.test.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { handleSandboxState } from "./sandbox"; +import { baseOptions, createDeps } from "./sandbox-test-fixtures"; + +describe("external component sandbox lifecycle", () => { + it.each([ + ["resume", { resume: true, recreateSandbox: () => false }], + ["recreation", { resume: false, recreateSandbox: () => true }], + ] as const)("rejects component onboarding during sandbox %s (#11340)", async (_case, mode) => { + const { deps, calls } = createDeps(); + + await expect( + handleSandboxState({ + ...baseOptions(deps), + ...mode, + externalComponentRegistered: true, + }), + ).rejects.toThrow( + "External component onboarding requires a new sandbox and cannot resume, reuse, repair, or recreate one.", + ); + + expect(calls.removeSandbox).not.toHaveBeenCalled(); + expect(calls.createSandbox).not.toHaveBeenCalled(); + expect(calls.updateSandbox).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index baf05d66277..2c743fddf76 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -187,6 +187,8 @@ export interface SandboxStateOptions< hermesPortableLifecycle?: boolean; /** Explicit fresh-create mode that lets APF supply the sandbox-scoped policy. */ apfInterceptorRequested?: boolean; + /** A validated external component limits this run to one new sandbox. */ + externalComponentRegistered?: boolean; /** Internal rebuild mode: null web-search state is an authoritative disable, not a prompt. */ authoritativeResumeConfig?: boolean; /** Explicit Deferred N1x managed-vLLM choice admitted by preflight. */ @@ -2556,6 +2558,14 @@ class SandboxStateFlow< this.applyObservabilityRequest(this.prepareWebSearchSupport()), ); const decision = await this.resolveResumeDecision(initialState); + if ( + this.options.externalComponentRegistered === true && + (this.options.resume || this.options.recreateSandbox(false) || decision.kind !== "create") + ) { + throw new Error( + "External component onboarding requires a new sandbox and cannot resume, reuse, repair, or recreate one.", + ); + } const completedState = decision.kind === "reuse" ? await this.reuseSandbox(initialState) diff --git a/src/lib/onboard/machine/initial-flow-composition.ts b/src/lib/onboard/machine/initial-flow-composition.ts index 6b8bce17669..fa99cd32b61 100644 --- a/src/lib/onboard/machine/initial-flow-composition.ts +++ b/src/lib/onboard/machine/initial-flow-composition.ts @@ -11,11 +11,9 @@ import { export { destroyGatewayForReuse } from "../gateway-cleanup"; export { verifyGatewayContainerRunning } from "../gateway-container-running"; +export * as externalComponent from "../external-component/onboarding"; export { applyHealthyPortReuse } from "./gateway-stale-port-reuse"; -export { - type InitialOnboardFlowContext, - runInitialOnboardFlowSlice, -} from "./initial-flow-phases"; +export { type InitialOnboardFlowContext, runInitialOnboardFlowSlice } from "./initial-flow-phases"; const gatewayDeps = { destroyGatewayForReuse, diff --git a/src/lib/onboard/machine/initial-flow-phases.ts b/src/lib/onboard/machine/initial-flow-phases.ts index 3431dec99d2..b79adcea28a 100644 --- a/src/lib/onboard/machine/initial-flow-phases.ts +++ b/src/lib/onboard/machine/initial-flow-phases.ts @@ -5,6 +5,10 @@ import { spawnSync } from "node:child_process"; import type { GatewayReuseState } from "../../state/gateway"; import { type GatewayOwner, isExternallySupervised } from "../gateway-ownership"; import { formatSandboxGpuPassthroughNote } from "../sandbox-gpu-notes"; +import { + ExternalComponentContractError, + type PreparedExternalComponent, +} from "../external-component"; import type { OnboardFlowContext } from "./flow-context"; import { UnexpectedOnboardFlowSliceStateError } from "./flow-slice-error"; import { runInitialOnboardFlowSequence } from "./flow-slices"; @@ -67,6 +71,7 @@ export interface InitialOnboardFlowPhaseOptions< >; getInitialGatewayReuseState(): GatewayReuseState; assertGatewayReadiness(): Promise; + prepareExternalComponent?(session: Context["session"]): PreparedExternalComponent | null; gatewayName: string; recreateSandbox(): boolean; requiresBindMounts?: boolean; @@ -192,6 +197,10 @@ export function createInitialOnboardFlowPhases< const gatewayPhase: OnboardSequencePhase = { state: "gateway", async run(context) { + const externalComponent = options.prepareExternalComponent?.(context.session) ?? null; + if (externalComponent && (context.resume || options.recreateSandbox())) { + throw new ExternalComponentContractError("lifecycle_unsupported"); + } const owner = options.gatewayDeps.resolveGatewayOwner(); await options.assertGatewayReadiness(); const gatewayResult = await handleGatewayState({ @@ -208,10 +217,11 @@ export function createInitialOnboardFlowPhases< requestedSandboxName: context.requestedSandboxName, recreateSandbox: options.recreateSandbox(), requiresBindMounts: options.requiresBindMounts === true, + externalComponent, deps: options.gatewayDeps, }); return { - context: { ...context, session: gatewayResult.session }, + context: { ...context, externalComponent, session: gatewayResult.session }, result: gatewayResult.stateResult, }; }, diff --git a/src/lib/state/onboard-session-external-component.test.ts b/src/lib/state/onboard-session-external-component.test.ts new file mode 100644 index 00000000000..1ea9fae88f6 --- /dev/null +++ b/src/lib/state/onboard-session-external-component.test.ts @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +let temporaryHome: string; + +const activationEvidence = { + schemaVersion: 1 as const, + activationId: "4b5a8e18-f967-4e27-a3b2-f2cc315abe21", + componentId: "policy-governance", + lifecycleGeneration: "generation-1", + sandboxIdentityFingerprint: `sha256:${"b".repeat(64)}`, + resultClass: "ambiguous" as const, +}; + +beforeEach(() => { + temporaryHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-component-session-")); + vi.stubEnv("HOME", temporaryHome); + vi.resetModules(); +}); + +afterEach(() => { + fs.rmSync(temporaryHome, { recursive: true, force: true }); + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +describe("external component activation session evidence", () => { + it("does not change persisted sessions without activation evidence (#11340)", async () => { + const sessionState = await import("./onboard-session.js"); + + sessionState.saveSession(sessionState.createSession()); + + expect(fs.readFileSync(sessionState.SESSION_FILE, "utf-8")).not.toContain( + "externalComponentActivation", + ); + }); + + it("persists bounded incomplete state without mutable sandbox identity (#11340)", async () => { + const sessionState = await import("./onboard-session.js"); + + sessionState.saveSession( + sessionState.createSession({ externalComponentActivation: activationEvidence }), + ); + + expect(sessionState.loadSession()?.externalComponentActivation).toEqual(activationEvidence); + const raw = fs.readFileSync(sessionState.SESSION_FILE, "utf-8"); + const persisted = JSON.parse(raw) as { + externalComponentActivation: Record; + }; + expect(persisted.externalComponentActivation).toEqual(activationEvidence); + expect(persisted.externalComponentActivation).not.toHaveProperty("sandboxName"); + expect(JSON.stringify(persisted.externalComponentActivation)).not.toMatch( + /credential|secret|token|password|api.?key/iu, + ); + }); + + it("rejects added fields and malformed durable evidence (#11340)", async () => { + const sessionState = await import("./onboard-session.js"); + const withSecret = { + ...activationEvidence, + credential: "do-not-persist", + }; + + expect( + sessionState.filterSafeUpdates({ + externalComponentActivation: withSecret, + } as never), + ).toEqual({}); + expect(() => + sessionState.normalizeSession({ + ...sessionState.createSession(), + externalComponentActivation: withSecret, + } as never), + ).toThrow(sessionState.InvalidPersistedExternalComponentActivationError); + }); +}); diff --git a/src/lib/state/onboard-session.ts b/src/lib/state/onboard-session.ts index 0d17065d3db..561e3750033 100644 --- a/src/lib/state/onboard-session.ts +++ b/src/lib/state/onboard-session.ts @@ -94,6 +94,7 @@ const SAFE_VLLM_INSTALL_MODEL = /^[A-Za-z0-9._:/-]+$/; export class InvalidPersistedApfInterceptorIntentError extends Error {} export class InvalidPersistedCancellationRecoveryError extends Error {} +export class InvalidPersistedExternalComponentActivationError extends Error {} // Session-specific aliases for the shared JSON types. type SessionJsonValue = JsonValue; @@ -139,6 +140,15 @@ export interface SessionCancellationRecovery { readonly recordedAt: string; } +export interface ExternalComponentActivationIncomplete { + readonly schemaVersion: 1; + readonly activationId: string; + readonly componentId: string; + readonly lifecycleGeneration: string; + readonly sandboxIdentityFingerprint: string; + readonly resultClass: "failed" | "ambiguous"; +} + function sameCancellationRecovery( left: SessionCancellationRecovery | null, right: SessionCancellationRecovery | null, @@ -255,6 +265,8 @@ export interface Session { lastCompletedStep: string | null; failure: SessionFailure | null; cancellationRecovery: SessionCancellationRecovery | null; + /** Secret-free evidence for one activation that did not reach verified success. */ + externalComponentActivation: ExternalComponentActivationIncomplete | null; agent: string | null; sandboxName: string | null; provider: string | null; @@ -373,6 +385,7 @@ export interface SessionUpdates { gpuPassthrough?: boolean; telegramConfig?: TelegramConfig | null; wechatConfig?: WechatConfig | null; + externalComponentActivation?: ExternalComponentActivationIncomplete | null; metadata?: { gatewayName?: string; fromDockerfile?: string | null }; /** Ephemeral vLLM checkpoint proof consumed by Station provider binding; never persisted. */ stationExpressModelIdentity?: string; @@ -409,6 +422,7 @@ export interface DebugSessionSummary { lastCompletedStep: string | null; failure: SessionFailure | null; cancellationRecovery: SessionCancellationRecovery | null; + externalComponentActivation: ExternalComponentActivationIncomplete | null; gatewayAuthority: GatewayOwnerDescription | null; machine: OnboardMachineSnapshot; steps: Record; @@ -843,6 +857,49 @@ function parseSessionCancellationRecovery( }; } +function parseExternalComponentActivation( + value: SessionJsonValue | undefined, +): ExternalComponentActivationIncomplete | null { + if (!isObject(value)) return null; + const activationId = readString(value.activationId); + const componentId = readString(value.componentId); + const lifecycleGeneration = readString(value.lifecycleGeneration); + const sandboxIdentityFingerprint = readString(value.sandboxIdentityFingerprint); + if ( + value.schemaVersion !== 1 || + !activationId || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(activationId) || + !componentId || + !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(componentId) || + !lifecycleGeneration || + !validSafeEvidence(lifecycleGeneration) || + !sandboxIdentityFingerprint || + !/^sha256:[0-9a-f]{64}$/u.test(sandboxIdentityFingerprint) || + (value.resultClass !== "failed" && value.resultClass !== "ambiguous") || + Object.keys(value).some( + (field) => + ![ + "schemaVersion", + "activationId", + "componentId", + "lifecycleGeneration", + "sandboxIdentityFingerprint", + "resultClass", + ].includes(field), + ) + ) { + return null; + } + return { + schemaVersion: 1, + activationId, + componentId, + lifecycleGeneration, + sandboxIdentityFingerprint, + resultClass: value.resultClass, + }; +} + // ── Session CRUD ───────────────────────────────────────────────── function createMachineSnapshot( @@ -942,6 +999,9 @@ export function createSession(overrides: Partial = {}): Session { cancellationRecovery: parseSessionCancellationRecovery( overrides.cancellationRecovery as SessionJsonValue | undefined, ), + externalComponentActivation: parseExternalComponentActivation( + overrides.externalComponentActivation as SessionJsonValue | undefined, + ), agent: overrides.agent ?? null, sandboxName: overrides.sandboxName ?? null, provider: overrides.provider ?? null, @@ -1068,6 +1128,18 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): "Refusing to load the onboarding session: saved recovery authority is incomplete.", ); } + const externalComponentActivation = parseExternalComponentActivation( + data.externalComponentActivation, + ); + if ( + hasOwn(data, "externalComponentActivation") && + data.externalComponentActivation !== null && + !externalComponentActivation + ) { + throw new InvalidPersistedExternalComponentActivationError( + "Refusing to load the onboarding session: saved external component activation evidence is incomplete.", + ); + } const normalized = createSession({ sessionId: readString(data.sessionId) ?? undefined, @@ -1110,6 +1182,7 @@ export function normalizeSession(data: Session | SessionJsonValue | undefined): lastCompletedStep: readString(data.lastCompletedStep), failure: sanitizeFailure(isObject(data.failure) ? data.failure : null), cancellationRecovery, + externalComponentActivation, metadata: parseSessionMetadata(data.metadata), checkpoint: data.checkpoint as unknown as OnboardCheckpoint | null, }); @@ -1221,7 +1294,10 @@ export function loadSession(): Session | null { if (lockOwned) assertOnboardLockOwned(); return normalized; } catch (error) { - if (error instanceof InvalidPersistedApfInterceptorIntentError) { + if ( + error instanceof InvalidPersistedApfInterceptorIntentError || + error instanceof InvalidPersistedExternalComponentActivationError + ) { throw error; } if (lockOwned) throw error; @@ -1232,8 +1308,10 @@ export function loadSession(): Session | null { } function serializeSessionForDisk(session: Session): Record { + const { externalComponentActivation, ...persistentSession } = session; return { - ...session, + ...persistentSession, + ...(externalComponentActivation ? { externalComponentActivation } : {}), messagingPlan: session.messagingPlan ? compactSandboxMessagingPlanForPersistence(session.messagingPlan) : session.messagingPlan, @@ -1881,6 +1959,14 @@ export function filterSafeUpdates(updates: SessionUpdates): Partial { } else if (updates.wechatConfig === null) { safe.wechatConfig = null; } + if (updates.externalComponentActivation === null) { + safe.externalComponentActivation = null; + } else { + const activation = parseExternalComponentActivation( + updates.externalComponentActivation as SessionJsonValue | undefined, + ); + if (activation) safe.externalComponentActivation = activation; + } if (isObject(updates.metadata) && typeof updates.metadata.gatewayName === "string") { safe.metadata = { gatewayName: updates.metadata.gatewayName, @@ -2610,6 +2696,7 @@ export function summarizeForDebug( lastCompletedStep: session.lastCompletedStep, failure: sanitizeFailure(session.failure), cancellationRecovery: session.cancellationRecovery, + externalComponentActivation: session.externalComponentActivation, gatewayAuthority, machine: session.machine, steps: Object.fromEntries( From 70c851bdb6cece2cb782a973ba0fb2ade587e1c6 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 9 Sep 2026 21:29:21 -0700 Subject: [PATCH 2/8] fix(onboard): preserve trusted gateway runtime template Signed-off-by: Apurv Kumaria --- src/lib/onboard.ts | 2 +- .../onboard/docker-driver-gateway-env.test.ts | 30 ++++++++++++ src/lib/onboard/docker-driver-gateway-env.ts | 21 ++++++-- .../docker-driver-gateway-runtime.test.ts | 17 ------- .../onboard/docker-driver-gateway-runtime.ts | 10 +--- .../onboard/external-component/index.test.ts | 20 ++++++-- .../onboard/external-component/onboarding.ts | 11 ++++- src/lib/onboard/gateway-reuse.ts | 15 +----- .../onboard/gateway/docker-driver-start.ts | 10 +--- src/lib/onboard/gateway/start.ts | 28 ++--------- .../onboard/machine/handlers/gateway.test.ts | 23 +++++++-- src/lib/onboard/machine/handlers/gateway.ts | 49 ++++++------------- .../machine/initial-flow-composition.test.ts | 3 ++ .../machine/initial-flow-composition.ts | 16 ++++-- .../machine/initial-flow-phases.test.ts | 2 + 15 files changed, 131 insertions(+), 126 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 26417a2f531..4f5b5f1bdfa 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -2929,7 +2929,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { }, getInitialGatewayReuseState: () => selectNamedGatewayForReuseIfNeeded(getGatewayReuseSnapshot()).gatewayReuseState, - ...component.initialFlowDeps(onboardPreflightGatewayAuthority), + ...component.initialFlowDeps(onboardPreflightGatewayAuthority, getDockerDriverGatewayEnv), gatewayName: GATEWAY_NAME, recreateSandbox: isRecreateSandbox, requiresBindMounts: effectiveHostMounts.length > 0, diff --git a/src/lib/onboard/docker-driver-gateway-env.test.ts b/src/lib/onboard/docker-driver-gateway-env.test.ts index a7f87001cb9..27e4363c58f 100644 --- a/src/lib/onboard/docker-driver-gateway-env.test.ts +++ b/src/lib/onboard/docker-driver-gateway-env.test.ts @@ -12,9 +12,11 @@ import { writeOpenShell0044PreAuthState } from "../../../test/support/openshell- import { buildDockerDriverGatewayEnv, buildDockerGatewayDebEnvFile, + configureDockerDriverGatewayExternalComponent, startPackageManagedDockerDriverGatewayWithEnvOverride, writeDockerGatewayDebEnvOverride, } from "./docker-driver-gateway-env"; +import { NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV } from "./docker-driver-gateway-config"; import { PORTABLE_HOST_GATEWAY_IP } from "./experimental/portable-profile"; function homeEnv(home: string, xdgConfigHome = ""): NodeJS.ProcessEnv { @@ -38,6 +40,34 @@ function trustedPackageServiceOptions(home: string) { } describe("buildDockerDriverGatewayEnv", () => { + it("adds the validated external component to NemoClaw gateway configuration (#11340)", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-gateway-component-env-")); + try { + fs.chmodSync(stateDir, 0o700); + const env = buildDockerDriverGatewayEnv({ + platform: "linux", + stateDir, + getDockerSupervisorImage: () => "supervisor:test", + resolveSandboxBin: () => "/usr/bin/openshell-sandbox", + }); + + configureDockerDriverGatewayExternalComponent( + env, + { + componentId: "policy-governance", + interceptorSocketPath: "/run/user/1000/component/interceptor.sock", + }, + ); + + expect(fs.readFileSync(env.OPENSHELL_GATEWAY_CONFIG, "utf8")).toContain( + 'name = "policy-governance"', + ); + expect(env[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]).toMatch(/^[0-9a-f]{64}$/u); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + it("uses the shared configured Docker network authority (#9461)", () => { vi.stubEnv("OPENSHELL_DOCKER_NETWORK_NAME", "openshell-portable-proof"); diff --git a/src/lib/onboard/docker-driver-gateway-env.ts b/src/lib/onboard/docker-driver-gateway-env.ts index fbfe6aa0660..e70d9c67549 100644 --- a/src/lib/onboard/docker-driver-gateway-env.ts +++ b/src/lib/onboard/docker-driver-gateway-env.ts @@ -92,7 +92,24 @@ export interface BuildDockerDriverGatewayEnvOptions { getDockerSupervisorImage: () => string; resolveSandboxBin: () => string | null; enableBindMounts?: boolean; - externalComponent?: ExternalComponentGatewayConfiguration | null; +} + +export function configureDockerDriverGatewayExternalComponent( + gatewayEnv: Record, + externalComponent: ExternalComponentGatewayConfiguration, +): void { + const configPath = gatewayEnv.OPENSHELL_GATEWAY_CONFIG; + if (!configPath) { + throw new Error("OpenShell Docker-driver gateway requires OPENSHELL_GATEWAY_CONFIG"); + } + prepareDockerDriverGatewayConfigEnv( + gatewayEnv, + path.dirname(configPath), + gatewayEnv.OPENSHELL_DOCKER_SUPERVISOR_BIN, + { + externalComponent, + }, + ); } function preparePortableGatewayHostRuntime( @@ -433,7 +450,6 @@ export function buildDockerDriverGatewayEnv({ getDockerSupervisorImage, resolveSandboxBin, enableBindMounts = false, - externalComponent, }: BuildDockerDriverGatewayEnvOptions): Record { const portable = isPortableExperimentalProfile(); const runtime = @@ -490,7 +506,6 @@ export function buildDockerDriverGatewayEnv({ allowOpenShell0044PreAuthDatabase: process.env.NEMOCLAW_RESTORE_LATEST_BACKUP_ON_RECREATE === "1", gatewayRuntime: runtime, - externalComponent, }); return env; } diff --git a/src/lib/onboard/docker-driver-gateway-runtime.test.ts b/src/lib/onboard/docker-driver-gateway-runtime.test.ts index e299f556985..366bedb1ebe 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.test.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.test.ts @@ -583,23 +583,6 @@ describe("docker-driver gateway runtime helpers", () => { ); }); - it("marks a gateway stale when its external component registration was removed (#11340)", () => { - const { helpers } = makeHelpers(); - expect( - helpers.getDockerDriverGatewayRuntimeDriftFromSnapshot({ - processEnv: { - OPENSHELL_DRIVERS: "docker", - [NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]: "prior-component", - }, - processExe: "/usr/bin/openshell-gateway", - desiredEnv: { OPENSHELL_DRIVERS: "docker" }, - gatewayBin: "/usr/bin/openshell-gateway", - })?.reason, - ).toBe( - `${NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV}=prior-component (expected )`, - ); - }); - it("reuses a systemd-owned gateway without detached cleanup identity (#6903)", () => { const pid = 12_350; const gatewayBin = "/usr/bin/openshell-gateway"; diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts index 2893e4e3db4..a934f07af52 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -10,7 +10,6 @@ import { isSupportedGatewayDockerHost } from "../domain/docker-host"; import { gatewayIdForStateDir, NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV, - type ExternalComponentGatewayConfiguration, } from "./docker-driver-gateway-config"; import { createDockerDriverGatewayPortListenerHelpers, @@ -102,7 +101,6 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa getDockerDriverGatewayEnv( versionOutput?: string | null, platform?: NodeJS.Platform, - externalComponent?: ExternalComponentGatewayConfiguration | null, ): Record; getDockerDriverGatewayPid(): number | null; getDockerDriverGatewayPidFile(): string; @@ -248,7 +246,6 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa function getDockerDriverGatewayEnv( versionOutput: string | null = null, platform: NodeJS.Platform = process.platform, - externalComponent?: ExternalComponentGatewayConfiguration | null, ): Record { const dockerHost = process.env.DOCKER_HOST; let podmanSocketPath: string | undefined; @@ -270,7 +267,6 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa getDockerSupervisorImage: () => getOpenShellDockerSupervisorImage(versionOutput), resolveSandboxBin: resolveOpenShellSandboxBinary, enableBindMounts: deps.enableBindMounts?.() === true, - externalComponent, }); if (gatewayEnv.OPENSHELL_LOCAL_TLS_DIR) { process.env.OPENSHELL_LOCAL_TLS_DIR = gatewayEnv.OPENSHELL_LOCAL_TLS_DIR; @@ -361,11 +357,7 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa const actual = processEnv[key]; const desired = desiredEnv[key]; if (typeof desired !== "string") { - if ( - (key === "NEMOCLAW_DOCKER_ENABLE_BIND_MOUNTS" || - key === "NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY") && - actual !== undefined - ) { + if (key === "NEMOCLAW_DOCKER_ENABLE_BIND_MOUNTS" && actual !== undefined) { return { reason: `${key}=${actual} (expected )` }; } continue; diff --git a/src/lib/onboard/external-component/index.test.ts b/src/lib/onboard/external-component/index.test.ts index 9b7ec190b46..058bfb7f14c 100644 --- a/src/lib/onboard/external-component/index.test.ts +++ b/src/lib/onboard/external-component/index.test.ts @@ -350,10 +350,22 @@ describe("external component declaration", () => { homeDirectory: endpointFixture.homeDirectory, platform: "linux", }); - await closeServer(endpointFixture.activationServer); - fs.rmSync(endpointFixture.activationSocketPath, { force: true }); - await listen(endpointFixture.activationSocketPath); + const realLstatSync = fs.lstatSync.bind(fs); + const lstatSync = vi.spyOn(fs, "lstatSync"); + lstatSync.mockImplementation((candidate) => { + const stat = realLstatSync(candidate); + return new Proxy(stat, { + get: (target, property, receiver) => + property === "ino" && String(candidate) === endpointFixture.activationSocketPath + ? target.ino + 1 + : Reflect.get(target, property, receiver), + }); + }); - expectReason(() => endpointPrepared?.revalidateBeforeActivation(), "socket_ambiguous"); + try { + expectReason(() => endpointPrepared?.revalidateBeforeActivation(), "socket_ambiguous"); + } finally { + lstatSync.mockRestore(); + } }); }); diff --git a/src/lib/onboard/external-component/onboarding.ts b/src/lib/onboard/external-component/onboarding.ts index 8f5959b9257..3821d9e35fc 100644 --- a/src/lib/onboard/external-component/onboarding.ts +++ b/src/lib/onboard/external-component/onboarding.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { inspectPolicyMutationContext } from "../../policy"; +import { configureDockerDriverGatewayExternalComponent } from "../docker-driver-gateway-env"; import { ExternalComponentContractError, loadExternalComponentDeclaration, @@ -21,9 +22,17 @@ export function prepareExternalComponent( return loadExternalComponentDeclaration(); } -export function initialFlowDeps(readiness: { collectGatewayReadiness(): Promise }) { +export function initialFlowDeps( + readiness: { collectGatewayReadiness(): Promise }, + getDockerDriverGatewayEnv: () => Record, +) { return { assertGatewayReadiness: () => readiness.collectGatewayReadiness().then(() => undefined), + configureExternalComponentGateway: (externalComponent: { + readonly componentId: string; + readonly interceptorSocketPath: string; + }) => + configureDockerDriverGatewayExternalComponent(getDockerDriverGatewayEnv(), externalComponent), prepareExternalComponent, }; } diff --git a/src/lib/onboard/gateway-reuse.ts b/src/lib/onboard/gateway-reuse.ts index 8d351becbf7..8ba82bd07cd 100644 --- a/src/lib/onboard/gateway-reuse.ts +++ b/src/lib/onboard/gateway-reuse.ts @@ -12,7 +12,6 @@ import { shouldSelectNamedGatewayForReuse, } from "../state/gateway"; import * as dockerDriverGatewayLaunch from "./docker-driver-gateway-launch"; -import type { ExternalComponentGatewayConfiguration } from "./docker-driver-gateway-config"; import { configuredRuntimeProviderOwnsHostReadiness } from "./docker-driver-gateway-env"; import * as gatewayService from "./docker-driver-gateway-service"; import type { PortProbeResult } from "./preflight"; @@ -44,11 +43,7 @@ export interface DockerDriverGatewayReuseApplicationDeps { getGatewayCompatContainerName(): string; isDockerDriverGatewayEnabled(): boolean; resolveOpenShellGatewayBinary(): string | null; - getDockerDriverGatewayEnv( - versionOutput?: string | null, - platform?: NodeJS.Platform, - externalComponent?: ExternalComponentGatewayConfiguration | null, - ): Record; + getDockerDriverGatewayEnv(versionOutput?: string | null): Record; runCaptureOpenshell(args: string[], opts?: { ignoreError?: boolean }): string; getDockerDriverGatewayStateDir(): string; resolveOpenShellSandboxBinary(): string | null; @@ -92,10 +87,7 @@ export type DockerDriverNetworkInspectRunner = ( ) => DockerDriverNetworkInspectCommandResult; export interface DockerDriverGatewayReuseApplication { - refreshDockerDriverGatewayReuseState( - state: GatewayReuseState, - externalComponent?: ExternalComponentGatewayConfiguration | null, - ): Promise; + refreshDockerDriverGatewayReuseState(state: GatewayReuseState): Promise; } function outputText(value: unknown): string { @@ -152,7 +144,6 @@ export function createDockerDriverGatewayReuseApplication( async function refreshDockerDriverGatewayReuseState( state: GatewayReuseState, - externalComponent?: ExternalComponentGatewayConfiguration | null, ): Promise { if (!deps.isDockerDriverGatewayEnabled() || state !== "healthy") return state; if (configuredRuntimeProviderOwnsHostReadiness()) return state; @@ -160,8 +151,6 @@ export function createDockerDriverGatewayReuseApplication( const gatewayBin = deps.resolveOpenShellGatewayBinary(); const baseDesiredEnv = deps.getDockerDriverGatewayEnv( deps.runCaptureOpenshell(["--version"], { ignoreError: true }), - undefined, - externalComponent, ); const runtimeIdentity = gatewayBin ? buildRuntimeIdentity({ diff --git a/src/lib/onboard/gateway/docker-driver-start.ts b/src/lib/onboard/gateway/docker-driver-start.ts index fd666750bac..cf15c928cd5 100644 --- a/src/lib/onboard/gateway/docker-driver-start.ts +++ b/src/lib/onboard/gateway/docker-driver-start.ts @@ -22,7 +22,6 @@ import * as dockerDriverGatewayRuntimeMarker from "../docker-driver-gateway-runt import * as gatewayStateLifecycleLock from "./state-lifecycle-lock"; import { formatGatewayHealthWaitLimit } from "../gateway-health-wait"; import { verifySandboxBridgeGatewayReachableOrExit } from "../gateway-sandbox-reachability"; -import type { ExternalComponentGatewayConfiguration } from "../docker-driver-gateway-config"; type GatewayRuntimeHelpers = ReturnType< typeof import("../docker-driver-gateway-runtime").createDockerDriverGatewayRuntimeHelpers @@ -74,7 +73,6 @@ export interface DockerDriverGatewayStartDeps { export interface DockerDriverGatewayStart { startDockerDriverGateway(options?: { exitOnFailure?: boolean; - externalComponent?: ExternalComponentGatewayConfiguration | null; runtimeSelection?: OpenShellRuntimeSelection; skipSandboxBridgeReachability?: boolean; }): Promise; @@ -92,12 +90,10 @@ export function createDockerDriverGatewayStart( ): DockerDriverGatewayStart { async function startDockerDriverGateway({ exitOnFailure = true, - externalComponent, runtimeSelection, skipSandboxBridgeReachability = false, }: { exitOnFailure?: boolean; - externalComponent?: ExternalComponentGatewayConfiguration | null; runtimeSelection?: OpenShellRuntimeSelection; skipSandboxBridgeReachability?: boolean; } = {}): Promise { @@ -148,11 +144,7 @@ export function createDockerDriverGatewayStart( } const gatewayBin = deps.resolveOpenShellGatewayBinary(); const openshellVersionOutput = runCaptureOpenshell(["--version"], { ignoreError: true }); - const gatewayEnv = deps.getDockerDriverGatewayEnv( - openshellVersionOutput, - undefined, - externalComponent, - ); + const gatewayEnv = deps.getDockerDriverGatewayEnv(openshellVersionOutput); const runtimeIdentity = gatewayBin ? dockerDriverGatewayLaunch.buildDockerDriverGatewayRuntimeIdentity({ gatewayBin, diff --git a/src/lib/onboard/gateway/start.ts b/src/lib/onboard/gateway/start.ts index cea949b7bca..c3ca3c24bf0 100644 --- a/src/lib/onboard/gateway/start.ts +++ b/src/lib/onboard/gateway/start.ts @@ -6,7 +6,6 @@ import { withSelectedOpenShellCommandOptions, } from "../../adapters/openshell/command-argv"; import { gatewayStartGuidance } from "../../gateway-start-guidance"; -import type { ExternalComponentGatewayConfiguration } from "../docker-driver-gateway-config"; import { normalizeGatewayStartError } from "../gateway-start-failure"; type OnboardGpu = ReturnType; @@ -39,7 +38,6 @@ export interface GatewayStartDeps { selectNamedGatewayForReuseIfNeeded: GatewayReuseHelpers["selectNamedGatewayForReuseIfNeeded"]; startDockerDriverGateway(options?: { exitOnFailure?: boolean; - externalComponent?: ExternalComponentGatewayConfiguration | null; runtimeSelection?: OpenShellRuntimeSelection; skipSandboxBridgeReachability?: boolean; }): Promise; @@ -47,18 +45,11 @@ export interface GatewayStartDeps { } export interface GatewayStart { - startGateway( - gpu: OnboardGpu, - options?: { - externalComponent?: ExternalComponentGatewayConfiguration | null; - gpuPassthrough?: boolean; - }, - ): Promise; + startGateway(gpu: OnboardGpu, options?: { gpuPassthrough?: boolean }): Promise; startGatewayWithOptions( gpu: OnboardGpu, options?: { exitOnFailure?: boolean; - externalComponent?: ExternalComponentGatewayConfiguration | null; gpuPassthrough?: boolean; runtimeSelection?: OpenShellRuntimeSelection; }, @@ -70,12 +61,10 @@ export function createGatewayStart(deps: GatewayStartDeps): GatewayStart { gpu: OnboardGpu, { exitOnFailure = true, - externalComponent, gpuPassthrough = false, runtimeSelection, }: { exitOnFailure?: boolean; - externalComponent?: ExternalComponentGatewayConfiguration | null; gpuPassthrough?: boolean; runtimeSelection?: OpenShellRuntimeSelection; } = {}, @@ -94,7 +83,6 @@ export function createGatewayStart(deps: GatewayStartDeps): GatewayStart { ); return deps.startDockerDriverGateway({ exitOnFailure, - externalComponent, ...(runtimeSelection ? { runtimeSelection } : {}), skipSandboxBridgeReachability: deps.dockerGpuLocalInference.shouldSkipGpuBridgeProbe( gpuPassthrough, @@ -138,19 +126,9 @@ export function createGatewayStart(deps: GatewayStartDeps): GatewayStart { async function startGateway( gpu: OnboardGpu, - { - externalComponent, - gpuPassthrough = false, - }: { - externalComponent?: ExternalComponentGatewayConfiguration | null; - gpuPassthrough?: boolean; - } = {}, + { gpuPassthrough = false }: { gpuPassthrough?: boolean } = {}, ): Promise { - return startGatewayWithOptions(gpu, { - exitOnFailure: true, - externalComponent, - gpuPassthrough, - }); + return startGatewayWithOptions(gpu, { exitOnFailure: true, gpuPassthrough }); } return { startGateway, startGatewayWithOptions }; diff --git a/src/lib/onboard/machine/handlers/gateway.test.ts b/src/lib/onboard/machine/handlers/gateway.test.ts index b5d88b30d41..8733c6951a5 100644 --- a/src/lib/onboard/machine/handlers/gateway.test.ts +++ b/src/lib/onboard/machine/handlers/gateway.test.ts @@ -55,6 +55,7 @@ function preparedExternalComponent(revalidateBeforeGateway = vi.fn()): PreparedE function createDeps(overrides: Partial["deps"]> = {}) { const calls = { + configureExternalComponentGateway: vi.fn(), refresh: vi.fn(async (state: GatewayReuseState) => state), lifecycle: vi.fn(() => false), verifyContainer: vi.fn((_gatewayName: string): GatewayContainerState => "running"), @@ -105,6 +106,7 @@ function createDeps(overrides: Partial["deps"]> = {}) { resolveGatewayOwner: calls.resolveOwner, attachGateway: calls.attachGateway, probeGatewayAttachment: calls.probeAttachment, + configureExternalComponentGateway: calls.configureExternalComponentGateway, refreshDockerDriverGatewayReuseState: calls.refresh, gatewayCliSupportsLifecycleCommands: calls.lifecycle, verifyGatewayContainerRunning: calls.verifyContainer, @@ -194,6 +196,7 @@ describe("handleGatewayState", () => { ).rejects.toThrow("declaration changed"); expect(revalidateBeforeGateway).toHaveBeenCalledOnce(); + expect(calls.configureExternalComponentGateway).not.toHaveBeenCalled(); expect(calls.refresh).not.toHaveBeenCalled(); expect(calls.startStep).not.toHaveBeenCalled(); expect(calls.retireLegacy).not.toHaveBeenCalled(); @@ -201,7 +204,8 @@ describe("handleGatewayState", () => { }); it("passes only the validated component projection to the managed gateway (#11340)", async () => { - const component = preparedExternalComponent(); + const revalidateBeforeGateway = vi.fn(); + const component = preparedExternalComponent(revalidateBeforeGateway); const { deps, calls } = createDeps({ isLinuxDockerDriverGatewayEnabled: vi.fn(() => true), }); @@ -216,10 +220,17 @@ describe("handleGatewayState", () => { interceptorSocketPath: "/run/user/1000/component/interceptor.sock", }; expect(component.revalidateBeforeGateway).toHaveBeenCalledOnce(); - expect(calls.refresh).toHaveBeenCalledWith("missing", projection); + expect(calls.configureExternalComponentGateway).toHaveBeenCalledWith(projection); + expect(calls.refresh).toHaveBeenCalledWith("missing"); expect(calls.startGateway).toHaveBeenCalledWith( { type: "nvidia" }, - { externalComponent: projection, gpuPassthrough: true }, + { gpuPassthrough: true }, + ); + expect(revalidateBeforeGateway.mock.invocationCallOrder[0]).toBeLessThan( + calls.configureExternalComponentGateway.mock.invocationCallOrder[0], + ); + expect(calls.configureExternalComponentGateway.mock.invocationCallOrder[0]).toBeLessThan( + calls.refresh.mock.invocationCallOrder[0], ); }); @@ -235,6 +246,7 @@ describe("handleGatewayState", () => { ).rejects.toMatchObject({ code: "capability_unsupported" }); expect(component.revalidateBeforeGateway).not.toHaveBeenCalled(); + expect(calls.configureExternalComponentGateway).not.toHaveBeenCalled(); expect(calls.refresh).not.toHaveBeenCalled(); expect(calls.startGateway).not.toHaveBeenCalled(); }); @@ -247,8 +259,9 @@ describe("handleGatewayState", () => { expect(calls.startStep).toHaveBeenCalledWith("gateway"); expect(calls.startGateway).toHaveBeenCalledWith( { type: "nvidia" }, - { externalComponent: null, gpuPassthrough: true }, + { gpuPassthrough: true }, ); + expect(calls.configureExternalComponentGateway).not.toHaveBeenCalled(); expect(calls.complete).toHaveBeenCalledWith("gateway"); expect(result.gatewayReuseState).toBe("missing"); expect(result.stateResult).toEqual({ @@ -299,7 +312,7 @@ describe("handleGatewayState", () => { expect(calls.startStep).toHaveBeenCalledWith("gateway"); expect(calls.startGateway).toHaveBeenCalledWith( { type: "nvidia" }, - { externalComponent: null, gpuPassthrough: true }, + { gpuPassthrough: true }, ); expect(calls.retireLegacy).not.toHaveBeenCalled(); expect(result.gatewayReuseState).toBe("stale"); diff --git a/src/lib/onboard/machine/handlers/gateway.ts b/src/lib/onboard/machine/handlers/gateway.ts index db364b8ce7e..cc4dfe4a38a 100644 --- a/src/lib/onboard/machine/handlers/gateway.ts +++ b/src/lib/onboard/machine/handlers/gateway.ts @@ -38,13 +38,11 @@ export interface GatewayStateOptions { resolveGatewayOwner(): GatewayOwner; probeGatewayAttachment(owner: GatewayOwner): Promise; attachGateway(owner: GatewayOwner, expectedProbe: GatewayAttachmentProbe): Promise; - refreshDockerDriverGatewayReuseState( - state: GatewayReuseState, - externalComponent?: { - readonly componentId: string; - readonly interceptorSocketPath: string; - } | null, - ): Promise; + configureExternalComponentGateway(component: { + readonly componentId: string; + readonly interceptorSocketPath: string; + }): void; + refreshDockerDriverGatewayReuseState(state: GatewayReuseState): Promise; gatewayCliSupportsLifecycleCommands(): boolean; verifyGatewayContainerRunning(gatewayName: string): GatewayContainerState; waitForGatewayHttpReady(): Promise; @@ -84,16 +82,7 @@ export interface GatewayStateOptions { ): Promise; note(message: string): void; startRecordedStep(stepName: string): Promise; - startGateway( - gpu: Gpu, - options: { - externalComponent?: { - readonly componentId: string; - readonly interceptorSocketPath: string; - } | null; - gpuPassthrough: boolean; - }, - ): Promise; + startGateway(gpu: Gpu, options: { gpuPassthrough: boolean }): Promise; recordStepComplete(stepName: string): Promise; exitProcess(code: number): never; }; @@ -159,16 +148,14 @@ async function handleGatewayStatePhase({ } externalComponent?.revalidateBeforeGateway(); + if (externalComponent) { + deps.configureExternalComponentGateway({ + componentId: externalComponent.declaration.componentId, + interceptorSocketPath: externalComponent.declaration.interceptorSocketPath, + }); + } - let gatewayReuseState = await deps.refreshDockerDriverGatewayReuseState( - initialGatewayReuseState, - externalComponent - ? { - componentId: externalComponent.declaration.componentId, - interceptorSocketPath: externalComponent.declaration.interceptorSocketPath, - } - : null, - ); + let gatewayReuseState = await deps.refreshDockerDriverGatewayReuseState(initialGatewayReuseState); const supportsLifecycleCommands = deps.gatewayCliSupportsLifecycleCommands(); if (gatewayReuseState === "healthy" && supportsLifecycleCommands) { @@ -308,15 +295,7 @@ async function handleGatewayStatePhase({ } else if (gatewayReuseState === "foreign-active") { gatewayReuseState = "missing"; } - await deps.startGateway(gpu, { - externalComponent: externalComponent - ? { - componentId: externalComponent.declaration.componentId, - interceptorSocketPath: externalComponent.declaration.interceptorSocketPath, - } - : null, - gpuPassthrough, - }); + await deps.startGateway(gpu, { gpuPassthrough }); session = await deps.recordStepComplete("gateway"); } diff --git a/src/lib/onboard/machine/initial-flow-composition.test.ts b/src/lib/onboard/machine/initial-flow-composition.test.ts index 3fd8c88831c..9d5af131582 100644 --- a/src/lib/onboard/machine/initial-flow-composition.test.ts +++ b/src/lib/onboard/machine/initial-flow-composition.test.ts @@ -30,8 +30,10 @@ describe("createInitialOnboardFlowPhases", () => { }); it("adds gateway verification and cleanup dependencies when it creates the initial phases (#7695)", () => { + const configureExternalComponentGateway = vi.fn(); const existingGatewayDependency = vi.fn(); const options = { + configureExternalComponentGateway, gatewayDeps: { existingGatewayDependency }, } as never; @@ -40,6 +42,7 @@ describe("createInitialOnboardFlowPhases", () => { expect(mocks.createInitialFlowPhases).toHaveBeenCalledWith({ gatewayDeps: { existingGatewayDependency, + configureExternalComponentGateway, destroyGatewayForReuse: mocks.destroyGatewayForReuse, verifyGatewayContainerRunning: mocks.verifyGatewayContainerRunning, }, diff --git a/src/lib/onboard/machine/initial-flow-composition.ts b/src/lib/onboard/machine/initial-flow-composition.ts index fa99cd32b61..5935568aab9 100644 --- a/src/lib/onboard/machine/initial-flow-composition.ts +++ b/src/lib/onboard/machine/initial-flow-composition.ts @@ -3,6 +3,7 @@ import { destroyGatewayForReuse } from "../gateway-cleanup"; import { verifyGatewayContainerRunning } from "../gateway-container-running"; +import type { initialFlowDeps as externalComponentInitialFlowDeps } from "../external-component/onboarding"; import { createInitialOnboardFlowPhases as createInitialFlowPhases, type InitialOnboardFlowContext, @@ -21,6 +22,10 @@ const gatewayDeps = { }; type GatewayDeps = typeof gatewayDeps; +type ExternalComponentGatewayDeps = Pick< + ReturnType, + "configureExternalComponentGateway" +>; export type InitialOnboardFlowCompositionOptions< Context extends InitialOnboardFlowContext, @@ -32,10 +37,11 @@ export type InitialOnboardFlowCompositionOptions< > = Omit< InitialOnboardFlowPhaseOptions, "gatewayDeps" -> & { +> & + ExternalComponentGatewayDeps & { gatewayDeps: Omit< InitialOnboardFlowPhaseOptions["gatewayDeps"], - keyof GatewayDeps + keyof GatewayDeps | keyof ExternalComponentGatewayDeps >; }; @@ -49,11 +55,13 @@ export function createInitialOnboardFlowPhases< >( options: InitialOnboardFlowCompositionOptions, ): ReturnType> { + const { configureExternalComponentGateway, ...phaseOptions } = options; return createInitialFlowPhases({ - ...options, + ...phaseOptions, gatewayDeps: { - ...options.gatewayDeps, + ...phaseOptions.gatewayDeps, ...gatewayDeps, + configureExternalComponentGateway, }, }); } diff --git a/src/lib/onboard/machine/initial-flow-phases.test.ts b/src/lib/onboard/machine/initial-flow-phases.test.ts index bdbab002d33..483f5646b4f 100644 --- a/src/lib/onboard/machine/initial-flow-phases.test.ts +++ b/src/lib/onboard/machine/initial-flow-phases.test.ts @@ -184,6 +184,7 @@ describe("initial onboard flow phases", () => { listenerExecPath: null, listenerSupervisorMatch: null, }), + configureExternalComponentGateway: vi.fn(), refreshDockerDriverGatewayReuseState: async (state) => state, gatewayCliSupportsLifecycleCommands: () => false, verifyGatewayContainerRunning: () => "running", @@ -453,6 +454,7 @@ describe("initial onboard flow phases", () => { listenerExecPath: null, listenerSupervisorMatch: null, }), + configureExternalComponentGateway: vi.fn(), refreshDockerDriverGatewayReuseState: vi.fn(async (state) => { calls.push("refresh-gateway-reuse"); return state; From 32e57cfdf7a3af23f055a754f30be4c4d51e85d5 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Wed, 9 Sep 2026 22:31:46 -0700 Subject: [PATCH 3/8] fix(onboard): remove stale component gateway authority Signed-off-by: Apurv Kumaria --- src/lib/onboard/docker-driver-gateway-env.ts | 2 +- .../onboard/external-component/onboarding.ts | 10 ++++---- .../onboard/machine/handlers/gateway.test.ts | 17 +++++++++++++ src/lib/onboard/machine/handlers/gateway.ts | 24 ++++++++++++------- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/lib/onboard/docker-driver-gateway-env.ts b/src/lib/onboard/docker-driver-gateway-env.ts index e70d9c67549..ab99dcd713b 100644 --- a/src/lib/onboard/docker-driver-gateway-env.ts +++ b/src/lib/onboard/docker-driver-gateway-env.ts @@ -96,7 +96,7 @@ export interface BuildDockerDriverGatewayEnvOptions { export function configureDockerDriverGatewayExternalComponent( gatewayEnv: Record, - externalComponent: ExternalComponentGatewayConfiguration, + externalComponent: ExternalComponentGatewayConfiguration | null, ): void { const configPath = gatewayEnv.OPENSHELL_GATEWAY_CONFIG; if (!configPath) { diff --git a/src/lib/onboard/external-component/onboarding.ts b/src/lib/onboard/external-component/onboarding.ts index 3821d9e35fc..d6d645f63eb 100644 --- a/src/lib/onboard/external-component/onboarding.ts +++ b/src/lib/onboard/external-component/onboarding.ts @@ -28,10 +28,12 @@ export function initialFlowDeps( ) { return { assertGatewayReadiness: () => readiness.collectGatewayReadiness().then(() => undefined), - configureExternalComponentGateway: (externalComponent: { - readonly componentId: string; - readonly interceptorSocketPath: string; - }) => + configureExternalComponentGateway: ( + externalComponent: { + readonly componentId: string; + readonly interceptorSocketPath: string; + } | null, + ) => configureDockerDriverGatewayExternalComponent(getDockerDriverGatewayEnv(), externalComponent), prepareExternalComponent, }; diff --git a/src/lib/onboard/machine/handlers/gateway.test.ts b/src/lib/onboard/machine/handlers/gateway.test.ts index 8733c6951a5..130c2116286 100644 --- a/src/lib/onboard/machine/handlers/gateway.test.ts +++ b/src/lib/onboard/machine/handlers/gateway.test.ts @@ -234,6 +234,23 @@ describe("handleGatewayState", () => { ); }); + it("removes a prior component before evaluating managed gateway reuse (#11340)", async () => { + const refresh = vi.fn(async () => "stale" as GatewayReuseState); + const { deps, calls } = createDeps({ + isLinuxDockerDriverGatewayEnabled: vi.fn(() => true), + refreshDockerDriverGatewayReuseState: refresh, + }); + + await handleGatewayState(baseOptions(deps, "healthy")); + + expect(calls.configureExternalComponentGateway).toHaveBeenCalledWith(null); + expect(calls.configureExternalComponentGateway.mock.invocationCallOrder[0]).toBeLessThan( + refresh.mock.invocationCallOrder[0], + ); + expect(calls.skipped).not.toHaveBeenCalled(); + expect(calls.startGateway).toHaveBeenCalledOnce(); + }); + it("rejects a registered component outside the supported Linux gateway path (#11340)", async () => { const component = preparedExternalComponent(); const { deps, calls } = createDeps(); diff --git a/src/lib/onboard/machine/handlers/gateway.ts b/src/lib/onboard/machine/handlers/gateway.ts index cc4dfe4a38a..71f575a12a0 100644 --- a/src/lib/onboard/machine/handlers/gateway.ts +++ b/src/lib/onboard/machine/handlers/gateway.ts @@ -38,10 +38,12 @@ export interface GatewayStateOptions { resolveGatewayOwner(): GatewayOwner; probeGatewayAttachment(owner: GatewayOwner): Promise; attachGateway(owner: GatewayOwner, expectedProbe: GatewayAttachmentProbe): Promise; - configureExternalComponentGateway(component: { - readonly componentId: string; - readonly interceptorSocketPath: string; - }): void; + configureExternalComponentGateway( + component: { + readonly componentId: string; + readonly interceptorSocketPath: string; + } | null, + ): void; refreshDockerDriverGatewayReuseState(state: GatewayReuseState): Promise; gatewayCliSupportsLifecycleCommands(): boolean; verifyGatewayContainerRunning(gatewayName: string): GatewayContainerState; @@ -148,11 +150,15 @@ async function handleGatewayStatePhase({ } externalComponent?.revalidateBeforeGateway(); - if (externalComponent) { - deps.configureExternalComponentGateway({ - componentId: externalComponent.declaration.componentId, - interceptorSocketPath: externalComponent.declaration.interceptorSocketPath, - }); + if (deps.isLinuxDockerDriverGatewayEnabled()) { + deps.configureExternalComponentGateway( + externalComponent + ? { + componentId: externalComponent.declaration.componentId, + interceptorSocketPath: externalComponent.declaration.interceptorSocketPath, + } + : null, + ); } let gatewayReuseState = await deps.refreshDockerDriverGatewayReuseState(initialGatewayReuseState); From 0491f9c7326db82e81b86721434977d04b7f9318 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 10 Sep 2026 00:12:57 -0700 Subject: [PATCH 4/8] fix(onboard): enforce component activation boundaries Signed-off-by: Apurv Kumaria --- src/lib/onboard.ts | 44 +++++----- .../external-component/activation.test.ts | 82 ++++++++++++++++--- .../onboard/external-component/activation.ts | 2 +- .../external-component/onboarding.test.ts | 46 ++++++++++- .../onboard/external-component/onboarding.ts | 33 +++++--- .../onboard/external-component/proof.test.ts | 8 +- src/lib/onboard/external-component/proof.ts | 3 +- .../onboard/machine/handlers/gateway.test.ts | 41 +++++++--- src/lib/onboard/machine/handlers/gateway.ts | 4 + .../machine/initial-flow-composition.test.ts | 3 + .../machine/initial-flow-composition.ts | 30 +++++-- .../machine/initial-flow-phases.test.ts | 2 + 12 files changed, 226 insertions(+), 72 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 4f5b5f1bdfa..757c0205e7a 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -1156,31 +1156,29 @@ const { gatewayClusterHealthcheckPassed, repairGatewayBootstrapSecrets } = // ── Step 1: Preflight ──────────────────────────────────────────── type PreflightOptions = import("./onboard/fatal-runtime-preflight").FatalRuntimePreflightOptions; -const onboardPreflightGatewayAuthority = - preflightGatewayAuthority.createOnboardPreflightGatewayAuthority({ - gatewayName: () => GATEWAY_NAME, - gatewayPort: () => GATEWAY_PORT, - collectGatewayReadiness: (deps) => - preflightGatewayAuthority.collectOnboardGatewayReadiness(deps), - getGatewayOwnerDeps: () => machineGatewayOwnerDeps, - isNonInteractive, - ensureOpenshellForOnboard, - updateSession: onboardSession.updateSession, - adoptPackagedGatewayAuthorityAfterTrustedInstall: - gatewayAuthorityCheckpoint.adoptPackagedGatewayAuthorityAfterTrustedInstall, - checkPortAvailable, - isDockerDriverGatewayPortListener, - getGatewayReuseSnapshot, - selectNamedGatewayForReuseIfNeeded, - refreshDockerDriverGatewayReuseState, - }); +const preflightGateway = preflightGatewayAuthority.createOnboardPreflightGatewayAuthority({ + gatewayName: () => GATEWAY_NAME, + gatewayPort: () => GATEWAY_PORT, + collectGatewayReadiness: (deps) => preflightGatewayAuthority.collectOnboardGatewayReadiness(deps), + getGatewayOwnerDeps: () => machineGatewayOwnerDeps, + isNonInteractive, + ensureOpenshellForOnboard, + updateSession: onboardSession.updateSession, + adoptPackagedGatewayAuthorityAfterTrustedInstall: + gatewayAuthorityCheckpoint.adoptPackagedGatewayAuthorityAfterTrustedInstall, + checkPortAvailable, + isDockerDriverGatewayPortListener, + getGatewayReuseSnapshot, + selectNamedGatewayForReuseIfNeeded, + refreshDockerDriverGatewayReuseState, +}); async function preflight( preflightOpts: PreflightOptions = {}, ): Promise> { step(1, 8, "Preflight checks"); const { gpu, host, sandboxGpuConfig, gpuTrustGateRejection } = - await onboardPreflightGatewayAuthority.runRuntimePreflight(preflightOpts); + await preflightGateway.runRuntimePreflight(preflightOpts); await preflightUtils.checkContainerRuntimeResources(host, { ignored: process.env.NEMOCLAW_IGNORE_RUNTIME_RESOURCES === "1", @@ -1192,7 +1190,7 @@ async function preflight( externallySupervised: gatewayExternallySupervised, gatewayReuseState: initialGatewayReuseState, managedGatewayObservationAuthoritative, - } = await onboardPreflightGatewayAuthority.prepareGatewayAuthority(); + } = await preflightGateway.prepareGatewayAuthority(); let reuseState = initialGatewayReuseState; // Docker-backed gateways use one legacy reuse and cleanup sequence because @@ -2605,7 +2603,7 @@ async function preflightAuthoritativeRebuildTarget( resolveBaselinePolicy: resolveSandboxBaselinePolicy, bindGatewayAuthority: () => bindGatewayOwner(getGatewayOwner()), runFatalRuntimePreflight: async () => - onboardPreflightGatewayAuthority.runRuntimePreflight( + preflightGateway.runRuntimePreflight( authoritativeRebuildTarget.authoritativeRebuildRuntimePreflightOptions(opts), (code) => fail(`onboard runtime preflight exited with code ${String(code)}`), ), @@ -2613,7 +2611,7 @@ async function preflightAuthoritativeRebuildTarget( ensureOpenshellForOnboard((code) => fail(`OpenShell component preflight exited with code ${String(code)}`), ), - assertGatewayReadiness: onboardPreflightGatewayAuthority.collectGatewayReadiness, + assertGatewayReadiness: preflightGateway.collectGatewayReadiness, inferenceRouteState: (p, m) => readInferenceRouteState(authoritativeGateway.name, p, m), captureForwardList: () => runCaptureOpenshell(["forward", "list"], { ignoreError: true }), }, @@ -2929,7 +2927,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise { }, getInitialGatewayReuseState: () => selectNamedGatewayForReuseIfNeeded(getGatewayReuseSnapshot()).gatewayReuseState, - ...component.initialFlowDeps(onboardPreflightGatewayAuthority, getDockerDriverGatewayEnv), + ...component.flowDeps(preflightGateway, getDockerDriverGatewayEnv, inspectSandboxForCreate), gatewayName: GATEWAY_NAME, recreateSandbox: isRecreateSandbox, requiresBindMounts: effectiveHostMounts.length > 0, diff --git a/src/lib/onboard/external-component/activation.test.ts b/src/lib/onboard/external-component/activation.test.ts index 753ce4b847b..b5ed61289cb 100644 --- a/src/lib/onboard/external-component/activation.test.ts +++ b/src/lib/onboard/external-component/activation.test.ts @@ -11,20 +11,22 @@ import { EXTERNAL_COMPONENT_ACTIVATION_TIMEOUT_MS, type PreparedExternalComponen import { activateExternalComponent, parseExternalComponentHttpResponse, - sendExternalComponentActivation, type ExternalComponentActivationProof, } from "./activation"; const policyHash = `sha256:${"a".repeat(64)}`; const identityFingerprint = `sha256:${"b".repeat(64)}`; -function fixture(events: string[] = []) { +function fixture( + events: string[] = [], + activationSocketPath = "/run/user/1000/component/activation.sock", +) { const component: PreparedExternalComponent = { declaration: { schemaVersion: 1, componentId: "policy-governance", interceptorSocketPath: "/run/user/1000/component/interceptor.sock", - activationSocketPath: "/run/user/1000/component/activation.sock", + activationSocketPath, }, revalidateBeforeGateway: vi.fn(), revalidateBeforeActivation: vi.fn(() => events.push("socket")), @@ -65,7 +67,7 @@ describe("external component activation", () => { expect(EXTERNAL_COMPONENT_ACTIVATION_TIMEOUT_MS).toBe(30_000); }); - it("uses one HTTP request over the declared activation socket (#11340)", async () => { + it("activates through one HTTP request over the declared socket (#11340)", async () => { const root = fs.mkdtempSync(path.join("/tmp", "nc-component-http-")); const socketPath = path.join(root, "activation.sock"); let resolveRequest!: (request: string) => void; @@ -78,7 +80,8 @@ describe("external component activation", () => { socket.on("end", () => { const request = Buffer.concat(chunks).toString("utf-8"); resolveRequest(request); - const responseBody = '{"result":"activated"}'; + const bodyStart = request.indexOf("\r\n\r\n") + 4; + const responseBody = responseFor(request.slice(bodyStart)); socket.end( `HTTP/1.1 200 OK\r\nContent-Length: ${String(Buffer.byteLength(responseBody))}\r\nConnection: close\r\n\r\n${responseBody}`, ); @@ -90,16 +93,73 @@ describe("external component activation", () => { }); try { - const requestBody = '{"schemaVersion":1}'; - await expect(sendExternalComponentActivation(socketPath, requestBody)).resolves.toBe( - '{"result":"activated"}', - ); + const { component, proof } = fixture([], socketPath); + await expect(activateExternalComponent(component, proof)).resolves.toEqual({ + kind: "activated", + }); const request = await received; expect(request).toContain("POST /v1/activate HTTP/1.1\r\n"); expect(request).toContain("Content-Type: application/json\r\n"); - expect(request).toContain(`Content-Length: ${String(Buffer.byteLength(requestBody))}\r\n`); - expect(request.endsWith(`\r\n\r\n${requestBody}`)).toBe(true); + expect(request).toContain('"componentId":"policy-governance"'); + expect(request).toContain('"source":"sandbox"'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("closes a pending activation socket when the fixed deadline expires (#11340)", async () => { + const root = fs.mkdtempSync(path.join("/tmp", "nc-component-timeout-")); + const socketPath = path.join(root, "activation.sock"); + let resolveAccepted!: () => void; + const accepted = new Promise((resolve) => { + resolveAccepted = resolve; + }); + const sockets: { client?: net.Socket; server?: net.Socket } = {}; + const server = net.createServer({ allowHalfOpen: true }, (socket) => { + sockets.server = socket; + socket.once("error", () => undefined); + resolveAccepted(); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + let expireDeadline!: () => void; + const deadline = { unref: vi.fn() } as unknown as NodeJS.Timeout; + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation((( + callback: () => void, + delay?: number, + ) => { + expect(delay).toBe(EXTERNAL_COMPONENT_ACTIVATION_TIMEOUT_MS); + expireDeadline = callback; + return deadline; + }) as typeof setTimeout); + const clearTimeoutSpy = vi + .spyOn(globalThis, "clearTimeout") + .mockImplementation((handle) => expect(handle).toBe(deadline)); + const createConnection = net.createConnection.bind(net); + const createConnectionSpy = vi.spyOn(net, "createConnection").mockImplementation(((options: { + path: string; + }) => { + const socket = createConnection(options); + sockets.client = socket; + return socket; + }) as typeof net.createConnection); + + try { + const { component, proof } = fixture([], socketPath); + const activation = activateExternalComponent(component, proof); + await accepted; + expireDeadline(); + + await expect(activation).resolves.toMatchObject({ kind: "ambiguous", reason: "timeout" }); + expect(sockets.client?.destroyed).toBe(true); } finally { + createConnectionSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + setTimeoutSpy.mockRestore(); + sockets.server?.destroy(); await new Promise((resolve) => server.close(() => resolve())); fs.rmSync(root, { recursive: true, force: true }); } diff --git a/src/lib/onboard/external-component/activation.ts b/src/lib/onboard/external-component/activation.ts index ef35eafb51e..27c29732653 100644 --- a/src/lib/onboard/external-component/activation.ts +++ b/src/lib/onboard/external-component/activation.ts @@ -35,7 +35,7 @@ export interface ExternalComponentActivationProof { readonly sandboxId: string; readonly sandboxIdentityFingerprint: string; readonly lifecycleGeneration: string; - readonly policySource: "sandbox" | "global"; + readonly policySource: "sandbox"; readonly policyHash: string; readonly policyActiveVersion: number; revalidate(operation: "before_handoff" | "after_activation"): void; diff --git a/src/lib/onboard/external-component/onboarding.test.ts b/src/lib/onboard/external-component/onboarding.test.ts index 6a884e7bb26..b20ece73dbb 100644 --- a/src/lib/onboard/external-component/onboarding.test.ts +++ b/src/lib/onboard/external-component/onboarding.test.ts @@ -1,12 +1,54 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ExternalComponentContractError } from "./index"; -import { prepareExternalComponent } from "./onboarding"; +import { assertExternalComponentFreshSandbox, prepareExternalComponent } from "./onboarding"; describe("external component onboarding lifecycle", () => { + it("accepts an explicit sandbox name when the sandbox is absent (#11340)", () => { + const inspectSandboxForCreate = vi.fn(() => ({ + existingEntry: null, + preservedMcpState: undefined, + liveExists: false, + })); + + expect(() => + assertExternalComponentFreshSandbox("new-sandbox", inspectSandboxForCreate), + ).not.toThrow(); + expect(inspectSandboxForCreate).toHaveBeenCalledWith("new-sandbox"); + }); + + it("requires an explicit sandbox name before gateway changes (#11340)", () => { + const inspectSandboxForCreate = vi.fn(); + + expect(() => assertExternalComponentFreshSandbox(null, inspectSandboxForCreate)).toThrowError( + expect.objectContaining>({ + code: "lifecycle_unsupported", + }), + ); + expect(inspectSandboxForCreate).not.toHaveBeenCalled(); + }); + + it.each([ + ["a registered sandbox", { existingEntry: {} as never, liveExists: false }], + ["a live sandbox", { existingEntry: null, liveExists: true }], + ])("rejects %s before gateway changes (#11340)", (_title, inspected) => { + const inspectSandboxForCreate = vi.fn(() => ({ + ...inspected, + preservedMcpState: undefined, + })); + + expect(() => + assertExternalComponentFreshSandbox("existing-sandbox", inspectSandboxForCreate), + ).toThrowError( + expect.objectContaining>({ + code: "lifecycle_unsupported", + }), + ); + }); + it("does not retry an incomplete activation automatically (#11340)", () => { expect(() => prepareExternalComponent({ diff --git a/src/lib/onboard/external-component/onboarding.ts b/src/lib/onboard/external-component/onboarding.ts index d6d645f63eb..135babfa116 100644 --- a/src/lib/onboard/external-component/onboarding.ts +++ b/src/lib/onboard/external-component/onboarding.ts @@ -2,7 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { inspectPolicyMutationContext } from "../../policy"; +import type { ExternalComponentActivationIncomplete } from "../../state/onboard-session"; import { configureDockerDriverGatewayExternalComponent } from "../docker-driver-gateway-env"; +import type { SandboxLifecycleHelpers } from "../sandbox-lifecycle"; import { ExternalComponentContractError, loadExternalComponentDeclaration, @@ -22,12 +24,28 @@ export function prepareExternalComponent( return loadExternalComponentDeclaration(); } -export function initialFlowDeps( +export function assertExternalComponentFreshSandbox( + requestedSandboxName: string | null, + inspectSandboxForCreate: SandboxLifecycleHelpers["inspectSandboxForCreate"], +): void { + if (!requestedSandboxName) { + throw new ExternalComponentContractError("lifecycle_unsupported"); + } + const inspected = inspectSandboxForCreate(requestedSandboxName); + if (inspected.existingEntry || inspected.liveExists) { + throw new ExternalComponentContractError("lifecycle_unsupported"); + } +} + +export function flowDeps( readiness: { collectGatewayReadiness(): Promise }, getDockerDriverGatewayEnv: () => Record, + inspectSandboxForCreate: SandboxLifecycleHelpers["inspectSandboxForCreate"], ) { return { assertGatewayReadiness: () => readiness.collectGatewayReadiness().then(() => undefined), + assertExternalComponentFreshSandbox: (requestedSandboxName: string | null) => + assertExternalComponentFreshSandbox(requestedSandboxName, inspectSandboxForCreate), configureExternalComponentGateway: ( externalComponent: { readonly componentId: string; @@ -39,19 +57,10 @@ export function initialFlowDeps( }; } -type ExternalComponentActivationEvidence = { - readonly schemaVersion: 1; - readonly activationId: string; - readonly componentId: string; - readonly lifecycleGeneration: string; - readonly sandboxIdentityFingerprint: string; - readonly resultClass: "failed" | "ambiguous"; -}; - interface OnboardSessionAccess { updateSession( mutator: (session: { - externalComponentActivation: ExternalComponentActivationEvidence | null; + externalComponentActivation: ExternalComponentActivationIncomplete | null; }) => void, ): unknown; } @@ -92,7 +101,7 @@ export function finalDeps( activationId: string, ) => activateExternalComponent(component, proof, undefined, activationId), setExternalComponentActivationEvidence: ( - evidence: ExternalComponentActivationEvidence | null, + evidence: ExternalComponentActivationIncomplete | null, ) => { onboardSession.updateSession((session) => { session.externalComponentActivation = evidence; diff --git a/src/lib/onboard/external-component/proof.test.ts b/src/lib/onboard/external-component/proof.test.ts index fba79038212..1bb5e261942 100644 --- a/src/lib/onboard/external-component/proof.test.ts +++ b/src/lib/onboard/external-component/proof.test.ts @@ -30,7 +30,7 @@ function fixture() { current_policy_version: 3, }; const inspection = { - policySource: "sandbox" as const, + policySource: "sandbox" as "sandbox" | "global", effectivePolicy: { version: 1, network_policies: { @@ -105,6 +105,12 @@ describe("external component activation proof", () => { value.inspection.policyIdentity.activeVersion = 4; }, ], + [ + "global policy authority", + (value: ReturnType) => { + value.inspection.policySource = "global"; + }, + ], [ "an invalid policy hash", (value: ReturnType) => { diff --git a/src/lib/onboard/external-component/proof.ts b/src/lib/onboard/external-component/proof.ts index b34f8981299..b427634c502 100644 --- a/src/lib/onboard/external-component/proof.ts +++ b/src/lib/onboard/external-component/proof.ts @@ -44,7 +44,7 @@ interface ProofSnapshot { readonly lifecycleGeneration: string; readonly policyActiveVersion: number; readonly policyHash: string; - readonly policySource: "sandbox" | "global"; + readonly policySource: "sandbox"; readonly sandboxId: string; readonly sandboxIdentityFingerprint: string; } @@ -83,6 +83,7 @@ function captureProofSnapshotUnchecked( ); if ( policy.gatewayName !== expectedGatewayName || + policy.inspection.policySource !== "sandbox" || row.current_policy_version !== policy.inspection.policyIdentity.activeVersion || !/^sha256:[0-9a-f]{64}$/u.test(policy.inspection.policyIdentity.hash) ) { diff --git a/src/lib/onboard/machine/handlers/gateway.test.ts b/src/lib/onboard/machine/handlers/gateway.test.ts index 130c2116286..663f6d32a53 100644 --- a/src/lib/onboard/machine/handlers/gateway.test.ts +++ b/src/lib/onboard/machine/handlers/gateway.test.ts @@ -55,6 +55,7 @@ function preparedExternalComponent(revalidateBeforeGateway = vi.fn()): PreparedE function createDeps(overrides: Partial["deps"]> = {}) { const calls = { + assertExternalComponentFreshSandbox: vi.fn(), configureExternalComponentGateway: vi.fn(), refresh: vi.fn(async (state: GatewayReuseState) => state), lifecycle: vi.fn(() => false), @@ -106,6 +107,7 @@ function createDeps(overrides: Partial["deps"]> = {}) { resolveGatewayOwner: calls.resolveOwner, attachGateway: calls.attachGateway, probeGatewayAttachment: calls.probeAttachment, + assertExternalComponentFreshSandbox: calls.assertExternalComponentFreshSandbox, configureExternalComponentGateway: calls.configureExternalComponentGateway, refreshDockerDriverGatewayReuseState: calls.refresh, gatewayCliSupportsLifecycleCommands: calls.lifecycle, @@ -180,6 +182,30 @@ function gatewaySpans(artifact: TraceArtifact) { } describe("handleGatewayState", () => { + it("rejects an existing sandbox before gateway mutation (#11340)", async () => { + const component = preparedExternalComponent(); + const { deps, calls } = createDeps({ + assertExternalComponentFreshSandbox: vi.fn(() => { + throw new Error("sandbox exists"); + }), + isLinuxDockerDriverGatewayEnabled: vi.fn(() => true), + }); + + await expect( + handleGatewayState({ + ...baseOptions(deps, "missing"), + externalComponent: component, + }), + ).rejects.toThrow("sandbox exists"); + + expect(deps.assertExternalComponentFreshSandbox).toHaveBeenCalledWith("my-assistant"); + expect(component.revalidateBeforeGateway).not.toHaveBeenCalled(); + expect(calls.configureExternalComponentGateway).not.toHaveBeenCalled(); + expect(calls.refresh).not.toHaveBeenCalled(); + expect(calls.startStep).not.toHaveBeenCalled(); + expect(calls.startGateway).not.toHaveBeenCalled(); + }); + it("validates the component before gateway configuration or lifecycle effects (#11340)", async () => { const revalidateBeforeGateway = vi.fn(() => { throw new Error("declaration changed"); @@ -222,10 +248,7 @@ describe("handleGatewayState", () => { expect(component.revalidateBeforeGateway).toHaveBeenCalledOnce(); expect(calls.configureExternalComponentGateway).toHaveBeenCalledWith(projection); expect(calls.refresh).toHaveBeenCalledWith("missing"); - expect(calls.startGateway).toHaveBeenCalledWith( - { type: "nvidia" }, - { gpuPassthrough: true }, - ); + expect(calls.startGateway).toHaveBeenCalledWith({ type: "nvidia" }, { gpuPassthrough: true }); expect(revalidateBeforeGateway.mock.invocationCallOrder[0]).toBeLessThan( calls.configureExternalComponentGateway.mock.invocationCallOrder[0], ); @@ -274,10 +297,7 @@ describe("handleGatewayState", () => { const result = await handleGatewayState(baseOptions(deps, "missing")); expect(calls.startStep).toHaveBeenCalledWith("gateway"); - expect(calls.startGateway).toHaveBeenCalledWith( - { type: "nvidia" }, - { gpuPassthrough: true }, - ); + expect(calls.startGateway).toHaveBeenCalledWith({ type: "nvidia" }, { gpuPassthrough: true }); expect(calls.configureExternalComponentGateway).not.toHaveBeenCalled(); expect(calls.complete).toHaveBeenCalledWith("gateway"); expect(result.gatewayReuseState).toBe("missing"); @@ -327,10 +347,7 @@ describe("handleGatewayState", () => { expect(calls.skipped).not.toHaveBeenCalled(); expect(calls.recordSkip).not.toHaveBeenCalled(); expect(calls.startStep).toHaveBeenCalledWith("gateway"); - expect(calls.startGateway).toHaveBeenCalledWith( - { type: "nvidia" }, - { gpuPassthrough: true }, - ); + expect(calls.startGateway).toHaveBeenCalledWith({ type: "nvidia" }, { gpuPassthrough: true }); expect(calls.retireLegacy).not.toHaveBeenCalled(); expect(result.gatewayReuseState).toBe("stale"); }); diff --git a/src/lib/onboard/machine/handlers/gateway.ts b/src/lib/onboard/machine/handlers/gateway.ts index 71f575a12a0..e5b3e4752f0 100644 --- a/src/lib/onboard/machine/handlers/gateway.ts +++ b/src/lib/onboard/machine/handlers/gateway.ts @@ -38,6 +38,7 @@ export interface GatewayStateOptions { resolveGatewayOwner(): GatewayOwner; probeGatewayAttachment(owner: GatewayOwner): Promise; attachGateway(owner: GatewayOwner, expectedProbe: GatewayAttachmentProbe): Promise; + assertExternalComponentFreshSandbox(requestedSandboxName: string | null): void; configureExternalComponentGateway( component: { readonly componentId: string; @@ -149,6 +150,9 @@ async function handleGatewayStatePhase({ ); } + if (externalComponent) { + deps.assertExternalComponentFreshSandbox(requestedSandboxName); + } externalComponent?.revalidateBeforeGateway(); if (deps.isLinuxDockerDriverGatewayEnabled()) { deps.configureExternalComponentGateway( diff --git a/src/lib/onboard/machine/initial-flow-composition.test.ts b/src/lib/onboard/machine/initial-flow-composition.test.ts index 9d5af131582..6da14355936 100644 --- a/src/lib/onboard/machine/initial-flow-composition.test.ts +++ b/src/lib/onboard/machine/initial-flow-composition.test.ts @@ -30,9 +30,11 @@ describe("createInitialOnboardFlowPhases", () => { }); it("adds gateway verification and cleanup dependencies when it creates the initial phases (#7695)", () => { + const assertExternalComponentFreshSandbox = vi.fn(); const configureExternalComponentGateway = vi.fn(); const existingGatewayDependency = vi.fn(); const options = { + assertExternalComponentFreshSandbox, configureExternalComponentGateway, gatewayDeps: { existingGatewayDependency }, } as never; @@ -42,6 +44,7 @@ describe("createInitialOnboardFlowPhases", () => { expect(mocks.createInitialFlowPhases).toHaveBeenCalledWith({ gatewayDeps: { existingGatewayDependency, + assertExternalComponentFreshSandbox, configureExternalComponentGateway, destroyGatewayForReuse: mocks.destroyGatewayForReuse, verifyGatewayContainerRunning: mocks.verifyGatewayContainerRunning, diff --git a/src/lib/onboard/machine/initial-flow-composition.ts b/src/lib/onboard/machine/initial-flow-composition.ts index 5935568aab9..9dcc27cba81 100644 --- a/src/lib/onboard/machine/initial-flow-composition.ts +++ b/src/lib/onboard/machine/initial-flow-composition.ts @@ -3,7 +3,7 @@ import { destroyGatewayForReuse } from "../gateway-cleanup"; import { verifyGatewayContainerRunning } from "../gateway-container-running"; -import type { initialFlowDeps as externalComponentInitialFlowDeps } from "../external-component/onboarding"; +import type { flowDeps as externalComponentFlowDeps } from "../external-component/onboarding"; import { createInitialOnboardFlowPhases as createInitialFlowPhases, type InitialOnboardFlowContext, @@ -23,8 +23,8 @@ const gatewayDeps = { type GatewayDeps = typeof gatewayDeps; type ExternalComponentGatewayDeps = Pick< - ReturnType, - "configureExternalComponentGateway" + ReturnType, + "assertExternalComponentFreshSandbox" | "configureExternalComponentGateway" >; export type InitialOnboardFlowCompositionOptions< @@ -39,11 +39,18 @@ export type InitialOnboardFlowCompositionOptions< "gatewayDeps" > & ExternalComponentGatewayDeps & { - gatewayDeps: Omit< - InitialOnboardFlowPhaseOptions["gatewayDeps"], - keyof GatewayDeps | keyof ExternalComponentGatewayDeps - >; -}; + gatewayDeps: Omit< + InitialOnboardFlowPhaseOptions< + Context, + Agent, + Gpu, + SandboxEntry, + Host, + Config + >["gatewayDeps"], + keyof GatewayDeps | keyof ExternalComponentGatewayDeps + >; + }; export function createInitialOnboardFlowPhases< Context extends InitialOnboardFlowContext, @@ -55,12 +62,17 @@ export function createInitialOnboardFlowPhases< >( options: InitialOnboardFlowCompositionOptions, ): ReturnType> { - const { configureExternalComponentGateway, ...phaseOptions } = options; + const { + assertExternalComponentFreshSandbox, + configureExternalComponentGateway, + ...phaseOptions + } = options; return createInitialFlowPhases({ ...phaseOptions, gatewayDeps: { ...phaseOptions.gatewayDeps, ...gatewayDeps, + assertExternalComponentFreshSandbox, configureExternalComponentGateway, }, }); diff --git a/src/lib/onboard/machine/initial-flow-phases.test.ts b/src/lib/onboard/machine/initial-flow-phases.test.ts index 483f5646b4f..4c380544da1 100644 --- a/src/lib/onboard/machine/initial-flow-phases.test.ts +++ b/src/lib/onboard/machine/initial-flow-phases.test.ts @@ -184,6 +184,7 @@ describe("initial onboard flow phases", () => { listenerExecPath: null, listenerSupervisorMatch: null, }), + assertExternalComponentFreshSandbox: vi.fn(), configureExternalComponentGateway: vi.fn(), refreshDockerDriverGatewayReuseState: async (state) => state, gatewayCliSupportsLifecycleCommands: () => false, @@ -454,6 +455,7 @@ describe("initial onboard flow phases", () => { listenerExecPath: null, listenerSupervisorMatch: null, }), + assertExternalComponentFreshSandbox: vi.fn(), configureExternalComponentGateway: vi.fn(), refreshDockerDriverGatewayReuseState: vi.fn(async (state) => { calls.push("refresh-gateway-reuse"); From a9e70bf121b98d72fd4d27b548a101a3fe46c6b9 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 10 Sep 2026 01:27:01 -0700 Subject: [PATCH 5/8] fix(onboard): honor external component lifecycle boundaries Signed-off-by: Apurv Kumaria --- .../docker-driver-gateway-runtime.test.ts | 17 ++ .../onboard/docker-driver-gateway-runtime.ts | 7 +- .../external-component/activation.test.ts | 70 +++++-- .../onboard/external-component/activation.ts | 32 +++- .../onboard/external-component/index.test.ts | 25 +++ src/lib/onboard/external-component/index.ts | 9 +- .../external-component/onboarding.test.ts | 175 +++++++++++++++++- .../onboard/external-component/onboarding.ts | 13 +- .../onboard/external-component/proof.test.ts | 6 + src/lib/onboard/external-component/proof.ts | 4 +- .../machine/initial-flow-phases.test.ts | 20 +- .../onboard/machine/initial-flow-phases.ts | 10 +- src/lib/onboard/session-bootstrap.test.ts | 37 ++++ src/lib/onboard/session-bootstrap.ts | 2 + .../onboard-fsm-live-slices.test.ts | 80 ++++++-- 15 files changed, 449 insertions(+), 58 deletions(-) diff --git a/src/lib/onboard/docker-driver-gateway-runtime.test.ts b/src/lib/onboard/docker-driver-gateway-runtime.test.ts index 366bedb1ebe..64a0a2a849a 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.test.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.test.ts @@ -583,6 +583,23 @@ describe("docker-driver gateway runtime helpers", () => { ); }); + it("marks a gateway stale when its external component identity is removed (#11340)", () => { + const { helpers } = makeHelpers(); + expect( + helpers.getDockerDriverGatewayRuntimeDriftFromSnapshot({ + processEnv: { + OPENSHELL_DRIVERS: "docker", + [NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]: "prior-component", + }, + processExe: "/usr/bin/openshell-gateway", + desiredEnv: { OPENSHELL_DRIVERS: "docker" }, + gatewayBin: "/usr/bin/openshell-gateway", + })?.reason, + ).toBe( + `${NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV}=prior-component (expected )`, + ); + }); + it("reuses a systemd-owned gateway without detached cleanup identity (#6903)", () => { const pid = 12_350; const gatewayBin = "/usr/bin/openshell-gateway"; diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts index a934f07af52..5b824c76686 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -9,6 +9,7 @@ import { isErrnoException } from "../core/errno"; import { isSupportedGatewayDockerHost } from "../domain/docker-host"; import { gatewayIdForStateDir, + NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV, NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV, } from "./docker-driver-gateway-config"; import { @@ -357,7 +358,11 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa const actual = processEnv[key]; const desired = desiredEnv[key]; if (typeof desired !== "string") { - if (key === "NEMOCLAW_DOCKER_ENABLE_BIND_MOUNTS" && actual !== undefined) { + if ( + actual !== undefined && + (key === "NEMOCLAW_DOCKER_ENABLE_BIND_MOUNTS" || + key === NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV) + ) { return { reason: `${key}=${actual} (expected )` }; } continue; diff --git a/src/lib/onboard/external-component/activation.test.ts b/src/lib/onboard/external-component/activation.test.ts index b5ed61289cb..42482260b93 100644 --- a/src/lib/onboard/external-component/activation.test.ts +++ b/src/lib/onboard/external-component/activation.test.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import fs from "node:fs"; +import http from "node:http"; import net from "node:net"; import path from "node:path"; @@ -67,42 +68,73 @@ describe("external component activation", () => { expect(EXTERNAL_COMPONENT_ACTIVATION_TIMEOUT_MS).toBe(30_000); }); - it("activates through one HTTP request over the declared socket (#11340)", async () => { + it("completes a delayed framed response before the component closes its socket (#11340)", async () => { const root = fs.mkdtempSync(path.join("/tmp", "nc-component-http-")); const socketPath = path.join(root, "activation.sock"); - let resolveRequest!: (request: string) => void; - const received = new Promise((resolve) => { + let serverSocket: net.Socket | undefined; + let clientSocket: net.Socket | undefined; + let resolveRequest!: (request: { + body: string; + contentType: string | undefined; + method: string | undefined; + url: string | undefined; + }) => void; + const received = new Promise[0]>((resolve) => { resolveRequest = resolve; }); - const server = net.createServer({ allowHalfOpen: true }, (socket) => { + const server = http.createServer((request) => { + serverSocket = request.socket; + request.socket.once("error", () => undefined); const chunks: Buffer[] = []; - socket.on("data", (chunk: Buffer) => chunks.push(chunk)); - socket.on("end", () => { - const request = Buffer.concat(chunks).toString("utf-8"); - resolveRequest(request); - const bodyStart = request.indexOf("\r\n\r\n") + 4; - const responseBody = responseFor(request.slice(bodyStart)); - socket.end( - `HTTP/1.1 200 OK\r\nContent-Length: ${String(Buffer.byteLength(responseBody))}\r\nConnection: close\r\n\r\n${responseBody}`, - ); + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + const activationRequest = { + body: Buffer.concat(chunks).toString("utf-8"), + contentType: request.headers["content-type"], + method: request.method, + url: request.url, + }; + resolveRequest(activationRequest); + setTimeout(() => { + const responseBody = responseFor(activationRequest.body); + request.socket.write( + `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${String(Buffer.byteLength(responseBody))}\r\nConnection: keep-alive\r\n\r\n${responseBody}`, + ); + }, 25); }); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(socketPath, resolve); }); + const createConnection = net.createConnection.bind(net); + const createConnectionSpy = vi.spyOn(net, "createConnection").mockImplementation(((options: { + path: string; + }) => { + const socket = createConnection(options); + clientSocket = socket; + return socket; + }) as typeof net.createConnection); try { const { component, proof } = fixture([], socketPath); - await expect(activateExternalComponent(component, proof)).resolves.toEqual({ + const activation = activateExternalComponent(component, proof); + const request = await received; + await expect(activation).resolves.toEqual({ kind: "activated", }); - const request = await received; - expect(request).toContain("POST /v1/activate HTTP/1.1\r\n"); - expect(request).toContain("Content-Type: application/json\r\n"); - expect(request).toContain('"componentId":"policy-governance"'); - expect(request).toContain('"source":"sandbox"'); + expect(request).toMatchObject({ + contentType: "application/json", + method: "POST", + url: "/v1/activate", + }); + expect(request.body).toContain('"componentId":"policy-governance"'); + expect(request.body).toContain('"source":"sandbox"'); + expect(clientSocket?.destroyed).toBe(true); + expect(serverSocket?.writableEnded).toBe(false); } finally { + createConnectionSpy.mockRestore(); + serverSocket?.destroy(); await new Promise((resolve) => server.close(() => resolve())); fs.rmSync(root, { recursive: true, force: true }); } diff --git a/src/lib/onboard/external-component/activation.ts b/src/lib/onboard/external-component/activation.ts index 27c29732653..d5d3fdb8f12 100644 --- a/src/lib/onboard/external-component/activation.ts +++ b/src/lib/onboard/external-component/activation.ts @@ -93,9 +93,13 @@ function parseActivationResponse(source: string): ActivationResponse | null { return value as unknown as ActivationResponse; } -export function parseExternalComponentHttpResponse(raw: Buffer): string { +function externalComponentHttpResponseBytes(raw: Buffer): number | null { const headerEnd = raw.indexOf("\r\n\r\n"); - if (headerEnd < 0 || headerEnd > RESPONSE_HEADER_MAX_BYTES) { + if (headerEnd < 0) { + if (raw.length > RESPONSE_HEADER_MAX_BYTES + 4) throw new Error("response_invalid"); + return null; + } + if (headerEnd > RESPONSE_HEADER_MAX_BYTES) { throw new Error("response_invalid"); } const headerText = raw.subarray(0, headerEnd).toString("ascii"); @@ -120,8 +124,15 @@ export function parseExternalComponentHttpResponse(raw: Buffer): string { if (!Number.isSafeInteger(length) || length > EXTERNAL_COMPONENT_MAX_RESPONSE_BYTES) { throw new Error("response_oversized"); } - const bodyStart = headerEnd + 4; - if (raw.length !== bodyStart + length) throw new Error("response_invalid"); + return headerEnd + 4 + length; +} + +export function parseExternalComponentHttpResponse(raw: Buffer): string { + const responseBytes = externalComponentHttpResponseBytes(raw); + if (responseBytes === null || raw.length !== responseBytes) { + throw new Error("response_invalid"); + } + const bodyStart = raw.indexOf("\r\n\r\n") + 4; return raw.subarray(bodyStart).toString("utf-8"); } @@ -131,6 +142,7 @@ export function sendExternalComponentActivation(socketPath: string, body: string const socket = net.createConnection({ path: socketPath }); const chunks: Buffer[] = []; let received = 0; + let responseBytes: number | null = null; let settled = false; const finish = (error?: Error, response?: string): void => { if (settled) return; @@ -146,7 +158,7 @@ export function sendExternalComponentActivation(socketPath: string, body: string ); deadline.unref(); socket.once("connect", () => { - socket.end( + socket.write( `POST /v1/activate HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nAccept: application/json\r\nContent-Length: ${String(request.length)}\r\nConnection: close\r\n\r\n${body}`, ); }); @@ -157,14 +169,18 @@ export function sendExternalComponentActivation(socketPath: string, body: string return; } chunks.push(chunk); - }); - socket.once("end", () => { try { - finish(undefined, parseExternalComponentHttpResponse(Buffer.concat(chunks))); + if (responseBytes === null) { + responseBytes = externalComponentHttpResponseBytes(Buffer.concat(chunks, received)); + } + if (responseBytes === null || received < responseBytes) return; + const raw = Buffer.concat(chunks, received); + finish(undefined, parseExternalComponentHttpResponse(raw)); } catch (error) { finish(error instanceof Error ? error : new Error("response_invalid")); } }); + socket.once("end", () => finish(new Error("response_invalid"))); socket.once("error", () => finish(new Error("connection"))); }); } diff --git a/src/lib/onboard/external-component/index.test.ts b/src/lib/onboard/external-component/index.test.ts index 058bfb7f14c..3e6c7738f59 100644 --- a/src/lib/onboard/external-component/index.test.ts +++ b/src/lib/onboard/external-component/index.test.ts @@ -89,6 +89,7 @@ async function preparedFixture() { homeDirectory, interceptorServer, interceptorSocketPath, + root, runtimeDirectory, }; } @@ -272,6 +273,30 @@ describe("external component declaration", () => { ); }); + it("rejects a symbolic-link socket parent as unsafe (#11340)", async () => { + const fixture = await preparedFixture(); + const linkedRuntimeDirectory = path.join(fixture.root, "runtime-link"); + fs.symlinkSync(fixture.runtimeDirectory, linkedRuntimeDirectory); + fs.writeFileSync( + fixture.declarationPath, + validJson({ + interceptorSocketPath: fixture.interceptorSocketPath, + activationSocketPath: path.join(linkedRuntimeDirectory, "activation.sock"), + }), + { mode: 0o600 }, + ); + + expectReason( + () => + loadExternalComponentDeclaration({ + declarationPath: fixture.declarationPath, + homeDirectory: fixture.homeDirectory, + platform: "linux", + }), + "socket_parent_unsafe", + ); + }); + it("rejects a socket not owned by the effective user (#11340)", async () => { const fixture = await preparedFixture(); const realLstatSync = fs.lstatSync.bind(fs); diff --git a/src/lib/onboard/external-component/index.ts b/src/lib/onboard/external-component/index.ts index 9f5af86f667..73ca310f9c1 100644 --- a/src/lib/onboard/external-component/index.ts +++ b/src/lib/onboard/external-component/index.ts @@ -308,7 +308,14 @@ function captureSafeParents( declaration ? "declaration_ambiguous" : "socket_parent_unsafe", ); } - const captured = identity(stat, "directory"); + let captured: FileIdentity; + try { + captured = identity(stat, "directory"); + } catch { + throw new ExternalComponentContractError( + declaration ? "declaration_ambiguous" : "socket_parent_unsafe", + ); + } const allowedOwner = declaration ? captured.uid === uid : captured.uid === uid || captured.uid === 0; diff --git a/src/lib/onboard/external-component/onboarding.test.ts b/src/lib/onboard/external-component/onboarding.test.ts index b20ece73dbb..b406d360418 100644 --- a/src/lib/onboard/external-component/onboarding.test.ts +++ b/src/lib/onboard/external-component/onboarding.test.ts @@ -1,12 +1,42 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; -import { ExternalComponentContractError } from "./index"; -import { assertExternalComponentFreshSandbox, prepareExternalComponent } from "./onboarding"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + inspectPolicyMutationContext: vi.fn(), + loadExternalComponentDeclaration: vi.fn(), +})); + +vi.mock("../../policy", async (importOriginal) => ({ + ...(await importOriginal()), + inspectPolicyMutationContext: mocks.inspectPolicyMutationContext, +})); + +vi.mock("./index", async (importOriginal) => ({ + ...(await importOriginal()), + loadExternalComponentDeclaration: mocks.loadExternalComponentDeclaration, +})); + +import { fingerprintOpenShellSandboxId } from "../../adapters/openshell/sandbox-identity"; +import { ExternalComponentContractError, type PreparedExternalComponent } from "./index"; +import { + assertExternalComponentFreshSandbox, + finalDeps, + prepareExternalComponent, +} from "./onboarding"; describe("external component onboarding lifecycle", () => { + beforeEach(() => { + mocks.inspectPolicyMutationContext.mockReset(); + mocks.loadExternalComponentDeclaration.mockReset(); + mocks.loadExternalComponentDeclaration.mockReturnValue(null); + }); + it("accepts an explicit sandbox name when the sandbox is absent (#11340)", () => { const inspectSandboxForCreate = vi.fn(() => ({ existingEntry: null, @@ -63,4 +93,143 @@ describe("external component onboarding lifecycle", () => { }), ); }); + + it("activates with final proof from registry, policy, and OpenShell evidence (#11340)", async () => { + const root = fs.mkdtempSync(path.join("/tmp", "nc-component-final-deps-")); + const socketPath = path.join(root, "activation.sock"); + const sandboxId = "sandbox-123"; + const sandboxName = "assistant"; + const gatewayName = "managed-gateway"; + const lifecycleGeneration = "generation-7"; + const policyHash = `sha256:${"a".repeat(64)}`; + const identityFingerprint = `sha256:${fingerprintOpenShellSandboxId(sandboxId)}`; + const registry = { + getSandbox: vi.fn(() => ({ + name: sandboxName, + gatewayName, + gatewayPort: 8080, + lifecycleGeneration, + lifecycleLiveIdentityFingerprint: fingerprintOpenShellSandboxId(sandboxId)!, + })), + setDefault: vi.fn(), + }; + const runCaptureOpenshell = vi.fn(() => + JSON.stringify([ + { + id: sandboxId, + name: sandboxName, + labels: {}, + resource_version: 9, + created_at: "2026-09-10T00:00:00Z", + phase: "Ready", + current_policy_version: 4, + }, + ]), + ); + mocks.inspectPolicyMutationContext.mockReturnValue({ + basePolicyDocument: + "version: 1\nnetwork_policies:\n inference:\n endpoints:\n - host: example.test\n", + gatewayName, + inspection: { + policySource: "sandbox", + effectivePolicy: { + version: 1, + network_policies: { + inference: { endpoints: [{ host: "example.test", port: 443 }] }, + }, + }, + policyIdentity: { hash: policyHash, activeVersion: 4 }, + }, + }); + let resolveRequest!: (request: Record) => void; + const received = new Promise>((resolve) => { + resolveRequest = resolve; + }); + const server = http.createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + const activationRequest = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as { + activationId: string; + componentId: string; + policy: { hash: string }; + sandbox: Record & { id: string }; + }; + resolveRequest(activationRequest); + const responseBody = JSON.stringify({ + schemaVersion: 1, + activationId: activationRequest.activationId, + componentId: activationRequest.componentId, + sandboxId: activationRequest.sandbox.id, + policyHash: activationRequest.policy.hash, + result: "activated", + }); + response.writeHead(200, { "Content-Length": String(Buffer.byteLength(responseBody)) }); + response.end(responseBody); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + + try { + const deps = finalDeps( + gatewayName, + { updateSession: vi.fn() }, + registry, + runCaptureOpenshell, + ); + const proof = deps.createExternalComponentActivationProof(sandboxName); + const component: PreparedExternalComponent = { + declaration: { + schemaVersion: 1, + componentId: "policy-governance", + interceptorSocketPath: path.join(root, "interceptor.sock"), + activationSocketPath: socketPath, + }, + revalidateBeforeGateway: vi.fn(), + revalidateBeforeActivation: vi.fn(), + }; + const result = await deps.activateExternalComponent( + component, + proof, + "123e4567-e89b-42d3-a456-426614174000", + ); + const activationRequest = await received; + + expect(result).toEqual({ kind: "activated" }); + expect(runCaptureOpenshell).toHaveBeenCalledWith( + ["sandbox", "list", "-g", gatewayName, "--output", "json"], + { ignoreError: false }, + ); + expect(mocks.inspectPolicyMutationContext).toHaveBeenCalledWith( + sandboxName, + "verify external component activation policy", + gatewayName, + ); + expect(activationRequest).toMatchObject({ + sandbox: { id: sandboxId, identityFingerprint, lifecycleGeneration }, + policy: { hash: policyHash }, + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects APF onboarding when a component is registered (#11340)", () => { + mocks.loadExternalComponentDeclaration.mockReturnValue({} as never); + + expect(() => + prepareExternalComponent({ + externalComponentActivation: null, + apfInterceptorRequested: true, + }), + ).toThrowError( + expect.objectContaining>({ + code: "lifecycle_unsupported", + }), + ); + }); }); diff --git a/src/lib/onboard/external-component/onboarding.ts b/src/lib/onboard/external-component/onboarding.ts index 135babfa116..94e888f6345 100644 --- a/src/lib/onboard/external-component/onboarding.ts +++ b/src/lib/onboard/external-component/onboarding.ts @@ -16,12 +16,23 @@ import { createExternalComponentActivationProof } from "./proof"; export function prepareExternalComponent( session: { externalComponentActivation?: unknown; + apfInterceptorRequested?: boolean | null; } | null, ): PreparedExternalComponent | null { + assertNoIncompleteExternalComponentActivation(session); + const externalComponent = loadExternalComponentDeclaration(); + if (externalComponent && session?.apfInterceptorRequested === true) { + throw new ExternalComponentContractError("lifecycle_unsupported"); + } + return externalComponent; +} + +export function assertNoIncompleteExternalComponentActivation( + session: { externalComponentActivation?: unknown } | null, +): void { if (session?.externalComponentActivation) { throw new ExternalComponentContractError("lifecycle_unsupported"); } - return loadExternalComponentDeclaration(); } export function assertExternalComponentFreshSandbox( diff --git a/src/lib/onboard/external-component/proof.test.ts b/src/lib/onboard/external-component/proof.test.ts index 1bb5e261942..79d2a55c560 100644 --- a/src/lib/onboard/external-component/proof.test.ts +++ b/src/lib/onboard/external-component/proof.test.ts @@ -94,6 +94,12 @@ describe("external component activation proof", () => { value.entry.gatewayName = "other"; }, ], + [ + "an unsafe lifecycle generation", + (value: ReturnType) => { + value.entry.lifecycleGeneration = "generation with spaces"; + }, + ], [ "ambiguous list rows", (value: ReturnType) => diff --git a/src/lib/onboard/external-component/proof.ts b/src/lib/onboard/external-component/proof.ts index b427634c502..4e9f8dad759 100644 --- a/src/lib/onboard/external-component/proof.ts +++ b/src/lib/onboard/external-component/proof.ts @@ -12,6 +12,7 @@ import { fingerprintOpenShellSandboxId, parseStrictOpenShellSandboxListJson, } from "../../adapters/openshell/sandbox-identity"; +import { validSafeEvidence } from "../../state/onboard-session/retained-sandbox-recovery"; import type { ExternalComponentActivationProof } from "./activation"; export class ExternalComponentProofError extends Error { @@ -61,8 +62,7 @@ function captureProofSnapshotUnchecked( entry.gatewayName !== expectedGatewayName || typeof entry.gatewayPort !== "number" || !Number.isSafeInteger(entry.gatewayPort) || - typeof entry.lifecycleGeneration !== "string" || - entry.lifecycleGeneration.length === 0 || + !validSafeEvidence(entry.lifecycleGeneration) || typeof entry.lifecycleLiveIdentityFingerprint !== "string" || !/^[0-9a-f]{64}$/u.test(entry.lifecycleLiveIdentityFingerprint) ) { diff --git a/src/lib/onboard/machine/initial-flow-phases.test.ts b/src/lib/onboard/machine/initial-flow-phases.test.ts index 4c380544da1..9acbd4f7afb 100644 --- a/src/lib/onboard/machine/initial-flow-phases.test.ts +++ b/src/lib/onboard/machine/initial-flow-phases.test.ts @@ -125,11 +125,15 @@ describe("initial onboard flow phases", () => { expect(getManagedReuseState).not.toHaveBeenCalled(); }); - it("carries preflight GPU output into the gateway phase", async () => { + it("validates an external component before preflight gateway effects (#11340)", async () => { const notes: string[] = []; const gpu: Gpu = { type: "nvidia", platform: "linux" }; let preflightFailure: Error | null = null; const commitSelectedAgentTransition = vi.fn(async () => createSession()); + const prepareExternalComponent = vi.fn(() => null); + const runPreflight = vi.fn(async () => + preflightFailure ? Promise.reject(preflightFailure) : gpu, + ); const phases = createInitialOnboardFlowPhases({ explicitSandboxGpuFlag: null, sandboxGpuDevice: null, @@ -145,7 +149,7 @@ describe("initial onboard flow phases", () => { getResumeSandboxGpuOverrides: () => ({ flag: null, device: null }), detectGpuForReadiness: () => gpu, detectGpu: () => gpu, - runPreflight: async () => (preflightFailure ? Promise.reject(preflightFailure) : gpu), + runPreflight, assessHost: () => ({}), providerNameToOptionKey: vi.fn(() => null), assertOnboardHostReadiness: vi.fn(), @@ -162,6 +166,7 @@ describe("initial onboard flow phases", () => { }, getInitialGatewayReuseState: () => "healthy", assertGatewayReadiness: vi.fn(async () => undefined), + prepareExternalComponent, gatewayName: "nemoclaw", recreateSandbox: () => false, gatewayDeps: { @@ -240,12 +245,23 @@ describe("initial onboard flow phases", () => { expect(notes).toContain( " GPU passthrough requested; passing --gpu to OpenShell gateway and sandbox creation.", ); + expect(prepareExternalComponent).toHaveBeenCalledOnce(); expect(commitSelectedAgentTransition).toHaveBeenCalledOnce(); commitSelectedAgentTransition.mockClear(); preflightFailure = new Error("readiness blocked"); await expect(phases[0].run(context())).rejects.toThrow("readiness blocked"); expect(commitSelectedAgentTransition).not.toHaveBeenCalled(); + + preflightFailure = null; + prepareExternalComponent.mockImplementation(() => { + throw new Error("invalid external component declaration"); + }); + runPreflight.mockClear(); + await expect(phases[0].run(context())).rejects.toThrow( + "invalid external component declaration", + ); + expect(runPreflight).not.toHaveBeenCalled(); }); it("repairs preflight before strict gateway entry", async () => { diff --git a/src/lib/onboard/machine/initial-flow-phases.ts b/src/lib/onboard/machine/initial-flow-phases.ts index b79adcea28a..7c50ea586fb 100644 --- a/src/lib/onboard/machine/initial-flow-phases.ts +++ b/src/lib/onboard/machine/initial-flow-phases.ts @@ -142,6 +142,10 @@ export function createInitialOnboardFlowPhases< const preflightPhase: OnboardSequencePhase = { state: "preflight", async run(context) { + const externalComponent = options.prepareExternalComponent?.(context.session) ?? null; + if (externalComponent && (context.resume || options.recreateSandbox())) { + throw new ExternalComponentContractError("lifecycle_unsupported"); + } const preflightResult = await handlePreflightState({ resume: context.resume, session: context.session, @@ -188,6 +192,7 @@ export function createInitialOnboardFlowPhases< preflightResult.deferredN1xManagedVllmPreviewAccepted, resumeHasResolvedGpuIntent: preflightResult.resumeHasResolvedGpuIntent, requestedGpuPassthrough: preflightResult.requestedGpuPassthrough, + externalComponent, }, result: preflightResult.stateResult, }; @@ -197,10 +202,7 @@ export function createInitialOnboardFlowPhases< const gatewayPhase: OnboardSequencePhase = { state: "gateway", async run(context) { - const externalComponent = options.prepareExternalComponent?.(context.session) ?? null; - if (externalComponent && (context.resume || options.recreateSandbox())) { - throw new ExternalComponentContractError("lifecycle_unsupported"); - } + const externalComponent = context.externalComponent ?? null; const owner = options.gatewayDeps.resolveGatewayOwner(); await options.assertGatewayReadiness(); const gatewayResult = await handleGatewayState({ diff --git a/src/lib/onboard/session-bootstrap.test.ts b/src/lib/onboard/session-bootstrap.test.ts index 7ed22afbb2a..32d3ff45de7 100644 --- a/src/lib/onboard/session-bootstrap.test.ts +++ b/src/lib/onboard/session-bootstrap.test.ts @@ -206,6 +206,43 @@ describe("prepareOnboardSession", () => { expect(deps.saveSession).not.toHaveBeenCalled(); }); + it("preserves incomplete activation evidence before fresh-session replacement (#11340)", async () => { + const activationEvidence = { + schemaVersion: 1 as const, + activationId: "4b5a8e18-f967-4e27-a3b2-f2cc315abe21", + componentId: "policy-governance", + lifecycleGeneration: "generation-1", + sandboxIdentityFingerprint: `sha256:${"b".repeat(64)}`, + resultClass: "ambiguous" as const, + }; + const existing = createSession({ + sessionId: "incomplete-activation", + externalComponentActivation: activationEvidence, + }); + const { deps, getSession } = createDeps(existing); + + await expect( + prepareOnboardSession( + { + resume: false, + fresh: true, + requestedFromDockerfile: null, + requestedSandboxName: "replacement", + cannotPrompt: true, + nonInteractive: true, + }, + deps, + ), + ).rejects.toMatchObject({ code: "lifecycle_unsupported" }); + + expect(getSession()).toBe(existing); + expect(getSession()?.externalComponentActivation).toEqual(activationEvidence); + expect(deps.requireHostMountRuntimeSupport).not.toHaveBeenCalled(); + expect(deps.clearSession).not.toHaveBeenCalled(); + expect(deps.createSession).not.toHaveBeenCalled(); + expect(deps.saveSession).not.toHaveBeenCalled(); + }); + it("creates a fresh session and records the resolved Dockerfile", async () => { const existing = createSession({ sessionId: "old-session" }); const { deps, getSession } = createDeps(existing); diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index d591ddc3f7a..aa249b3d854 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -28,6 +28,7 @@ import { import { recordCheckpointSandboxIdentity } from "./checkpoint-record"; import { checkpointProvesSandboxStepComplete } from "./checkpoint-replay"; import { EXPERIMENTAL_PROFILE_ENV } from "./docker-driver-platform"; +import { assertNoIncompleteExternalComponentActivation } from "./external-component/onboarding"; import type { PortableInferenceActivation } from "./experimental/portable-inference-descriptor"; import { requireReadOnlyHostMountRuntimeSupport } from "./host-mount"; import type { ResumeConfigConflict } from "./resume-config"; @@ -633,6 +634,7 @@ function prepareFreshSession( if (input.apfInterceptorRequested === true && input.checkpointProfile === "portable") { reportUnsupportedApfLifecycle("portable", deps); } + assertNoIncompleteExternalComponentActivation(deps.loadSession()); deps.requireHostMountRuntimeSupport(input.requestedHostMounts, input.checkpointProfile); if (input.fresh) { deps.clearSession(); diff --git a/test/onboarding/onboard-fsm-live-slices.test.ts b/test/onboarding/onboard-fsm-live-slices.test.ts index 6db7fdc1710..a99d52ea907 100644 --- a/test/onboarding/onboard-fsm-live-slices.test.ts +++ b/test/onboarding/onboard-fsm-live-slices.test.ts @@ -23,6 +23,7 @@ type ProbeMode = | "authoritative-core-gateway-policy-tier" | "dashboard-port-composition" | "ordinary-policy-tier" + | "providerless-external-component" | "providerless-staged-messaging" | "stale-recovery-admission" | "stale-session-decision" @@ -218,6 +219,9 @@ function runSliceProbe(options: ProbeOptions) { const finalizationDepsPath = JSON.stringify( path.join(repoRoot, "src", "lib", "onboard", "machine", "finalization-deps.ts"), ); + const externalComponentPath = JSON.stringify( + path.join(repoRoot, "src", "lib", "onboard", "external-component", "index.ts"), + ); fs.writeFileSync( scriptPath, @@ -237,6 +241,13 @@ const called = []; const sentinel = new Error("slice-called"); const staleAdmissionExit = new Error("stale recovery admission refused"); +if (scenario.mode === "providerless-external-component") { + require(${externalComponentPath}).loadExternalComponentDeclaration = () => { + called.push("component-validated"); + return {}; + }; +} + if (scenario.mode === "dashboard-port-composition") { const finalizationHandlerDeps = require(${finalizationDepsPath}).finalizationHandlerDeps; finalizationHandlerDeps.checkAndRecoverSandboxProcesses = () => undefined; @@ -341,6 +352,21 @@ function baseContext(context, overrides = {}) { } preflightHandlers.handlePreflightState = async (options) => { + if (scenario.mode === "providerless-external-component") { + called.push("preflight-effect"); + return { + gpu: null, + sandboxGpuConfig: { sandboxGpuEnabled: false, mode: "0" }, + resumePreflight: false, + resumeHasResolvedGpuIntent: false, + requestedGpuPassthrough: false, + gpuPassthrough: false, + effectiveSandboxGpuFlag: "disable", + effectiveSandboxGpuDevice: null, + session: options.session, + stateResult: advanceTo("gateway", { metadata: { state: "preflight" } }), + }; + } if (scenario.mode.includes("core-gateway")) { return { gpu: null, @@ -359,6 +385,9 @@ preflightHandlers.handlePreflightState = async (options) => { }; gatewayHandlers.handleGatewayState = async (options) => { + if (scenario.mode === "providerless-external-component") { + called.push("gateway-effect"); + } if (!scenario.mode.includes("core-gateway")) { throw new Error("unexpected gateway compatibility handler"); } @@ -380,23 +409,29 @@ providerHandlers.handleProviderInferenceState = async (options) => { throw sentinel; }; -flowSlices.runInitialOnboardFlowSequence = async ({ context, runtime }) => { - const initialSession = await runtime.session(); - called.push("initial:" + initialSession.machine.state); - if (scenario.slice === "initial") throw sentinel; - if (initialSession.machine?.state === "init") { - await runtime.applyResult(advanceTo("preflight")); - } - await runtime.applyResult(advanceTo("gateway", { metadata: { state: "preflight" } })); - await runtime.applyResult(advanceTo("provider_selection", { metadata: { state: "gateway" } })); - if (scenario.mode === "ahead-core") { - await runtime.applyResult(advanceTo("inference", { metadata: { state: "provider_selection" } })); - } - const session = await runtime.session(); - return { context: baseContext(context, { session }), session }; -}; +if (scenario.mode !== "providerless-external-component") { + flowSlices.runInitialOnboardFlowSequence = async ({ context, runtime }) => { + const initialSession = await runtime.session(); + called.push("initial:" + initialSession.machine.state); + if (scenario.slice === "initial") throw sentinel; + if (initialSession.machine?.state === "init") { + await runtime.applyResult(advanceTo("preflight")); + } + await runtime.applyResult(advanceTo("gateway", { metadata: { state: "preflight" } })); + await runtime.applyResult(advanceTo("provider_selection", { metadata: { state: "gateway" } })); + if (scenario.mode === "ahead-core") { + await runtime.applyResult(advanceTo("inference", { metadata: { state: "provider_selection" } })); + } + const session = await runtime.session(); + return { context: baseContext(context, { session }), session }; + }; +} flowSlices.runCoreOnboardFlowSequence = async ({ context, runtime }) => { + if (scenario.mode === "providerless-external-component") { + called.push("sandbox-effect"); + throw sentinel; + } called.push("core"); if (scenario.mode === "ahead-core") { throw new Error("strict core runner should not run after an ahead-state handoff"); @@ -535,7 +570,9 @@ const { onboard } = require(${onboardPath}); acceptThirdPartySoftware: true, noGpu: true, sandboxName: "fsm-sandbox", - apfInterceptorRequested: scenario.mode === "providerless-staged-messaging", + apfInterceptorRequested: + scenario.mode === "providerless-staged-messaging" || + scenario.mode === "providerless-external-component", resume: scenario.mode === "resume-initial" || scenario.mode.includes("core-gateway"), ...(scenario.mode.startsWith("authoritative-") ? { @@ -557,7 +594,9 @@ const { onboard } = require(${onboardPath}); (scenario.mode === "endpoint-override" && error?.name === "OpenShellGatewayEndpointOverrideError") || (scenario.mode === "providerless-staged-messaging" && - /supports providerless sandbox creation only/.test(String(error?.message))) + /supports providerless sandbox creation only/.test(String(error?.message))) || + (scenario.mode === "providerless-external-component" && + error?.code === "lifecycle_unsupported") ) { const payload = "__RESULT__" + JSON.stringify({ called }); if (scenario.mode === "dashboard-port-composition") { @@ -648,6 +687,13 @@ describe("live onboard FSM slice boundaries", () => { ); }); + it("rejects a registered component with providerless APF before effects (#11340)", () => { + assert.deepEqual( + runSliceProbe({ slice: "initial", mode: "providerless-external-component" }), + ["component-validated"], + ); + }); + it("rechecks retained sandbox admission after acquiring the onboarding lock (#9833)", () => { assert.deepEqual(runSliceProbe({ slice: "initial", mode: "stale-recovery-admission" }), []); }); From 45d62309bb1dbbb1bf371944099566b295a52feb Mon Sep 17 00:00:00 2001 From: San Dang Date: Thu, 10 Sep 2026 16:12:05 +0700 Subject: [PATCH 6/8] fix(onboard): preserve component recovery and runtime trust Signed-off-by: San Dang --- .../docker-driver-gateway-config-toml.test.ts | 2 +- .../onboard/docker-driver-gateway-config.ts | 10 +-- .../docker-driver-gateway-runtime.test.ts | 74 ++++++++++++------- .../onboard/docker-driver-gateway-runtime.ts | 7 +- .../external-component/activation.test.ts | 67 ++++++++++++----- .../onboard/external-component/activation.ts | 15 +++- .../onboard/external-component/index.test.ts | 13 ++++ .../onboard/external-component/proof.test.ts | 6 +- src/lib/onboard/session-bootstrap.test.ts | 73 +++++++++--------- src/lib/onboard/session-bootstrap.ts | 1 + 10 files changed, 168 insertions(+), 100 deletions(-) diff --git a/src/lib/onboard/docker-driver-gateway-config-toml.test.ts b/src/lib/onboard/docker-driver-gateway-config-toml.test.ts index d104945cac1..758daac29c2 100644 --- a/src/lib/onboard/docker-driver-gateway-config-toml.test.ts +++ b/src/lib/onboard/docker-driver-gateway-config-toml.test.ts @@ -165,7 +165,7 @@ describe("docker-driver-gateway config TOML", () => { expect(fs.readFileSync(env.OPENSHELL_GATEWAY_CONFIG, "utf-8")).not.toContain( "[[openshell.gateway.interceptors]]", ); - expect(env[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]).toBeUndefined(); + expect(env[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]).toBe("none"); expect(hashDockerDriverGatewayEnv(env)).not.toBe(componentRuntimeIdentity); } finally { fs.rmSync(stateDir, { recursive: true, force: true }); diff --git a/src/lib/onboard/docker-driver-gateway-config.ts b/src/lib/onboard/docker-driver-gateway-config.ts index 471cc525a88..aca419d4a5f 100644 --- a/src/lib/onboard/docker-driver-gateway-config.ts +++ b/src/lib/onboard/docker-driver-gateway-config.ts @@ -1060,12 +1060,10 @@ export function prepareDockerDriverGatewayConfigEnv( runtime, externalComponent, ); - if (externalComponent) { - gatewayEnv[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV] = - externalComponentGatewayIdentity(externalComponent); - } else { - delete gatewayEnv[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]; - } + // An explicit absence lets the existing runtime comparison detect removal. + gatewayEnv[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV] = externalComponent + ? externalComponentGatewayIdentity(externalComponent) + : "none"; if (runtime.gatewayConfig.sandboxNamespace === "omitted") { delete gatewayEnv[NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV]; } else { diff --git a/src/lib/onboard/docker-driver-gateway-runtime.test.ts b/src/lib/onboard/docker-driver-gateway-runtime.test.ts index 64a0a2a849a..d4a06b74359 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.test.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.test.ts @@ -133,16 +133,13 @@ describe("docker-driver gateway runtime helpers", () => { it.each([ ["relative", "relative-gateway-state"], ["shared root", path.join(os.homedir(), ".local", "state", "nemoclaw")], - ])( - "rejects a %s state-directory override through the binding owner", - (_scenario, configured) => { - withEnv({ NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR: configured }, () => { - expect(() => makeHelpers().helpers.getDockerDriverGatewayStateDir()).toThrow( - /absolute dedicated gateway state directory|shared NemoClaw state root/, - ); - }); - }, - ); + ])("rejects a %s state-directory override through the binding owner", (_scenario, configured) => { + withEnv({ NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR: configured }, () => { + expect(() => makeHelpers().helpers.getDockerDriverGatewayStateDir()).toThrow( + /absolute dedicated gateway state directory|shared NemoClaw state root/, + ); + }); + }); it("uses the moving dev supervisor image for an explicit or detected dev runtime", () => { const explicit = makeHelpers({ shouldUseOpenshellDevChannel: () => true }); @@ -285,8 +282,7 @@ describe("docker-driver gateway runtime helpers", () => { const gone = new Error("ESRCH") as NodeJS.ErrnoException; gone.code = "ESRCH"; throw gone; - })() - ) as typeof process.kill); + })()) as typeof process.kill); const originalExistsSync = fs.existsSync.bind(fs); const originalReadFileSync = fs.readFileSync.bind(fs); const replacementCmdline = `/proc/${String(replacementPid)}/cmdline`; @@ -294,15 +290,13 @@ describe("docker-driver gateway runtime helpers", () => { vi.spyOn(fs, "existsSync").mockImplementation(((candidate) => candidate === gatewayBin || candidate === replacementCmdline ? true - : originalExistsSync(candidate) - ) as typeof fs.existsSync); + : originalExistsSync(candidate)) as typeof fs.existsSync); vi.spyOn(fs, "readFileSync").mockImplementation(((candidate, options) => candidate === replacementCmdline ? `${gatewayBin}\0` : candidate === replacementEnvironment ? `NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE=${namespace}\0` - : originalReadFileSync(candidate, options as never) - ) as typeof fs.readFileSync); + : originalReadFileSync(candidate, options as never)) as typeof fs.readFileSync); expect(helpers.isDockerDriverGatewayStateInUse()).toBe(true); }); @@ -583,21 +577,45 @@ describe("docker-driver gateway runtime helpers", () => { ); }); - it("marks a gateway stale when its external component identity is removed (#11340)", () => { + it("marks a gateway stale after persisted external component removal (#11340)", () => { const { helpers } = makeHelpers(); - expect( - helpers.getDockerDriverGatewayRuntimeDriftFromSnapshot({ - processEnv: { - OPENSHELL_DRIVERS: "docker", - [NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]: "prior-component", - }, + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nc-component-removal-")); + try { + fs.chmodSync(stateDir, 0o700); + const options = { + platform: "linux" as const, + stateDir, + getDockerSupervisorImage: () => "supervisor:test", + resolveSandboxBin: () => "/usr/bin/openshell-sandbox", + }; + const env = dockerDriverGatewayEnv.buildDockerDriverGatewayEnv(options); + dockerDriverGatewayEnv.configureDockerDriverGatewayExternalComponent(env, { + componentId: "policy-governance", + interceptorSocketPath: "/run/user/1000/component/interceptor.sock", + }); + const processEnv = { ...env }; + dockerDriverGatewayEnv.configureDockerDriverGatewayExternalComponent(env, null); + const desiredEnv = dockerDriverGatewayEnv.buildDockerDriverGatewayEnv(options); + const snapshot = { processExe: "/usr/bin/openshell-gateway", - desiredEnv: { OPENSHELL_DRIVERS: "docker" }, gatewayBin: "/usr/bin/openshell-gateway", - })?.reason, - ).toBe( - `${NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV}=prior-component (expected )`, - ); + desiredEnv, + }; + expect(desiredEnv[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]).toBe("none"); + expect( + helpers.getDockerDriverGatewayRuntimeDriftFromSnapshot({ ...snapshot, processEnv })?.reason, + ).toBe( + `${NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV}=${processEnv[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]} (expected none)`, + ); + expect( + helpers.getDockerDriverGatewayRuntimeDriftFromSnapshot({ + ...snapshot, + processEnv: desiredEnv, + }), + ).toBeNull(); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } }); it("reuses a systemd-owned gateway without detached cleanup identity (#6903)", () => { diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts index 5b824c76686..a934f07af52 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -9,7 +9,6 @@ import { isErrnoException } from "../core/errno"; import { isSupportedGatewayDockerHost } from "../domain/docker-host"; import { gatewayIdForStateDir, - NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV, NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV, } from "./docker-driver-gateway-config"; import { @@ -358,11 +357,7 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa const actual = processEnv[key]; const desired = desiredEnv[key]; if (typeof desired !== "string") { - if ( - actual !== undefined && - (key === "NEMOCLAW_DOCKER_ENABLE_BIND_MOUNTS" || - key === NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV) - ) { + if (key === "NEMOCLAW_DOCKER_ENABLE_BIND_MOUNTS" && actual !== undefined) { return { reason: `${key}=${actual} (expected )` }; } continue; diff --git a/src/lib/onboard/external-component/activation.test.ts b/src/lib/onboard/external-component/activation.test.ts index 42482260b93..c4dbd736b4d 100644 --- a/src/lib/onboard/external-component/activation.test.ts +++ b/src/lib/onboard/external-component/activation.test.ts @@ -12,6 +12,7 @@ import { EXTERNAL_COMPONENT_ACTIVATION_TIMEOUT_MS, type PreparedExternalComponen import { activateExternalComponent, parseExternalComponentHttpResponse, + sendExternalComponentActivation, type ExternalComponentActivationProof, } from "./activation"; @@ -68,11 +69,10 @@ describe("external component activation", () => { expect(EXTERNAL_COMPONENT_ACTIVATION_TIMEOUT_MS).toBe(30_000); }); - it("completes a delayed framed response before the component closes its socket (#11340)", async () => { + it("completes a delayed response without half-closing the request (#11340)", async () => { const root = fs.mkdtempSync(path.join("/tmp", "nc-component-http-")); const socketPath = path.join(root, "activation.sock"); let serverSocket: net.Socket | undefined; - let clientSocket: net.Socket | undefined; let resolveRequest!: (request: { body: string; contentType: string | undefined; @@ -82,7 +82,7 @@ describe("external component activation", () => { const received = new Promise[0]>((resolve) => { resolveRequest = resolve; }); - const server = http.createServer((request) => { + const server = http.createServer((request, response) => { serverSocket = request.socket; request.socket.once("error", () => undefined); const chunks: Buffer[] = []; @@ -97,9 +97,8 @@ describe("external component activation", () => { resolveRequest(activationRequest); setTimeout(() => { const responseBody = responseFor(activationRequest.body); - request.socket.write( - `HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${String(Buffer.byteLength(responseBody))}\r\nConnection: keep-alive\r\n\r\n${responseBody}`, - ); + response.writeHead(200, { "Content-Length": String(Buffer.byteLength(responseBody)) }); + response.end(responseBody); }, 25); }); }); @@ -107,14 +106,6 @@ describe("external component activation", () => { server.once("error", reject); server.listen(socketPath, resolve); }); - const createConnection = net.createConnection.bind(net); - const createConnectionSpy = vi.spyOn(net, "createConnection").mockImplementation(((options: { - path: string; - }) => { - const socket = createConnection(options); - clientSocket = socket; - return socket; - }) as typeof net.createConnection); try { const { component, proof } = fixture([], socketPath); @@ -130,16 +121,58 @@ describe("external component activation", () => { }); expect(request.body).toContain('"componentId":"policy-governance"'); expect(request.body).toContain('"source":"sandbox"'); - expect(clientSocket?.destroyed).toBe(true); - expect(serverSocket?.writableEnded).toBe(false); } finally { - createConnectionSpy.mockRestore(); serverSocket?.destroy(); await new Promise((resolve) => server.close(() => resolve())); fs.rmSync(root, { recursive: true, force: true }); } }); + it.each([ + { delivery: "one chunk", firstCopies: 2, lastCopies: 0 }, + { delivery: "separate chunks", firstCopies: 1, lastCopies: 1 }, + ])("rejects extra responses in $delivery (#11340)", async ({ firstCopies, lastCopies }) => { + const root = fs.mkdtempSync("/tmp/nc-extra-response-"); + const socketPath = `${root}/activation.sock`; + const body = JSON.stringify({ + schemaVersion: 1, + activationId: "4b5a8e18-f967-4e27-a3b2-f2cc315abe21", + componentId: "policy-governance", + sandboxId: "sandbox-123", + policyHash: `sha256:${"a".repeat(64)}`, + result: "activated", + }); + const response = `HTTP/1.1 200 OK\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`; + let serverSocket: net.Socket | undefined; + let done!: () => void; + const sent = new Promise((resolve) => { + done = resolve; + }); + const server = net.createServer({ allowHalfOpen: true }, (socket) => { + serverSocket = socket; + socket.on("error", () => undefined); + socket.once("data", () => { + socket.write(response.repeat(firstCopies)); + setTimeout(() => { + socket.end(response.repeat(lastCopies)); + done(); + }, 20); + }); + }); + await new Promise((resolve) => server.listen(socketPath, resolve)); + try { + const result = await sendExternalComponentActivation(socketPath, "{}").catch( + (error: Error) => error.message, + ); + await sent; + expect(result).toBe("response_invalid"); + } finally { + serverSocket?.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(root, { force: true, recursive: true }); + } + }); + it("closes a pending activation socket when the fixed deadline expires (#11340)", async () => { const root = fs.mkdtempSync(path.join("/tmp", "nc-component-timeout-")); const socketPath = path.join(root, "activation.sock"); diff --git a/src/lib/onboard/external-component/activation.ts b/src/lib/onboard/external-component/activation.ts index d5d3fdb8f12..ab5df87a63a 100644 --- a/src/lib/onboard/external-component/activation.ts +++ b/src/lib/onboard/external-component/activation.ts @@ -173,14 +173,21 @@ export function sendExternalComponentActivation(socketPath: string, body: string if (responseBytes === null) { responseBytes = externalComponentHttpResponseBytes(Buffer.concat(chunks, received)); } - if (responseBytes === null || received < responseBytes) return; - const raw = Buffer.concat(chunks, received); - finish(undefined, parseExternalComponentHttpResponse(raw)); + if (responseBytes !== null && received > responseBytes) { + finish(new Error("response_invalid")); + } + } catch (error) { + finish(error instanceof Error ? error : new Error("response_invalid")); + } + }); + socket.once("end", () => { + try { + // EOF proves there is no second response after the declared body. + finish(undefined, parseExternalComponentHttpResponse(Buffer.concat(chunks, received))); } catch (error) { finish(error instanceof Error ? error : new Error("response_invalid")); } }); - socket.once("end", () => finish(new Error("response_invalid"))); socket.once("error", () => finish(new Error("connection"))); }); } diff --git a/src/lib/onboard/external-component/index.test.ts b/src/lib/onboard/external-component/index.test.ts index 3e6c7738f59..a8032ca7c57 100644 --- a/src/lib/onboard/external-component/index.test.ts +++ b/src/lib/onboard/external-component/index.test.ts @@ -65,6 +65,19 @@ async function closeServer(server: net.Server): Promise { } async function preparedFixture() { + const ancestors = path + .dirname(process.cwd()) + .split(path.sep) + .map((_part, index, parts) => parts.slice(0, index + 1).join(path.sep) || path.sep); + for (const ancestor of ancestors) { + const stat = fs.lstatSync(ancestor); + expect( + stat.isDirectory() && + (stat.mode & 0o022) === 0 && + (stat.uid === 0 || stat.uid === process.geteuid?.()), + `External component fixtures require a protected ancestor: ${ancestor}`, + ).toBe(true); + } const root = fs.mkdtempSync(path.join(path.dirname(process.cwd()), "nc-component-test-")); roots.push(root); fs.chmodSync(root, 0o700); diff --git a/src/lib/onboard/external-component/proof.test.ts b/src/lib/onboard/external-component/proof.test.ts index 79d2a55c560..00d947e5dee 100644 --- a/src/lib/onboard/external-component/proof.test.ts +++ b/src/lib/onboard/external-component/proof.test.ts @@ -139,11 +139,9 @@ describe("external component activation proof", () => { }); it("rejects identity or policy changes during revalidation (#11340)", () => { - const { deps, row } = fixture(); + const { deps, entry } = fixture(); const proof = createExternalComponentActivationProof("assistant", "nemoclaw", deps); - deps.listSandboxes.mockReturnValue( - JSON.stringify([{ ...row, current_policy_version: row.current_policy_version + 1 }]), - ); + entry.lifecycleGeneration = "generation-2"; expect(() => proof.revalidate("after_activation")).toThrow(ExternalComponentProofError); }); diff --git a/src/lib/onboard/session-bootstrap.test.ts b/src/lib/onboard/session-bootstrap.test.ts index 32d3ff45de7..b0381409dee 100644 --- a/src/lib/onboard/session-bootstrap.test.ts +++ b/src/lib/onboard/session-bootstrap.test.ts @@ -206,42 +206,47 @@ describe("prepareOnboardSession", () => { expect(deps.saveSession).not.toHaveBeenCalled(); }); - it("preserves incomplete activation evidence before fresh-session replacement (#11340)", async () => { - const activationEvidence = { - schemaVersion: 1 as const, - activationId: "4b5a8e18-f967-4e27-a3b2-f2cc315abe21", - componentId: "policy-governance", - lifecycleGeneration: "generation-1", - sandboxIdentityFingerprint: `sha256:${"b".repeat(64)}`, - resultClass: "ambiguous" as const, - }; - const existing = createSession({ - sessionId: "incomplete-activation", - externalComponentActivation: activationEvidence, - }); - const { deps, getSession } = createDeps(existing); + it.each([false, true])( + "preserves incomplete activation evidence with resume=%s (#11340)", + async (resume) => { + const activationEvidence = { + schemaVersion: 1 as const, + activationId: "4b5a8e18-f967-4e27-a3b2-f2cc315abe21", + componentId: "policy-governance", + lifecycleGeneration: "generation-1", + sandboxIdentityFingerprint: `sha256:${"b".repeat(64)}`, + resultClass: "ambiguous" as const, + }; + const existing = createSession({ + sessionId: "incomplete-activation", + externalComponentActivation: activationEvidence, + }); + const { deps, getSession } = createDeps(existing); - await expect( - prepareOnboardSession( - { - resume: false, - fresh: true, - requestedFromDockerfile: null, - requestedSandboxName: "replacement", - cannotPrompt: true, - nonInteractive: true, - }, - deps, - ), - ).rejects.toMatchObject({ code: "lifecycle_unsupported" }); + await expect( + prepareOnboardSession( + { + resume, + fresh: !resume, + requestedFromDockerfile: null, + requestedSandboxName: "replacement", + cannotPrompt: true, + nonInteractive: true, + }, + deps, + ), + ).rejects.toMatchObject({ code: "lifecycle_unsupported" }); - expect(getSession()).toBe(existing); - expect(getSession()?.externalComponentActivation).toEqual(activationEvidence); - expect(deps.requireHostMountRuntimeSupport).not.toHaveBeenCalled(); - expect(deps.clearSession).not.toHaveBeenCalled(); - expect(deps.createSession).not.toHaveBeenCalled(); - expect(deps.saveSession).not.toHaveBeenCalled(); - }); + expect(getSession()).toBe(existing); + expect(getSession()?.externalComponentActivation).toEqual(activationEvidence); + expect(deps.requireHostMountRuntimeSupport).not.toHaveBeenCalled(); + expect(deps.updateSession).not.toHaveBeenCalled(); + expect(deps.applySessionRecovery).not.toHaveBeenCalled(); + expect(deps.clearSession).not.toHaveBeenCalled(); + expect(deps.createSession).not.toHaveBeenCalled(); + expect(deps.saveSession).not.toHaveBeenCalled(); + }, + ); it("creates a fresh session and records the resolved Dockerfile", async () => { const existing = createSession({ sessionId: "old-session" }); diff --git a/src/lib/onboard/session-bootstrap.ts b/src/lib/onboard/session-bootstrap.ts index aa249b3d854..ebb6e310af8 100644 --- a/src/lib/onboard/session-bootstrap.ts +++ b/src/lib/onboard/session-bootstrap.ts @@ -575,6 +575,7 @@ async function prepareResumeSession( deps: OnboardSessionBootstrapDeps, ): Promise { let session = deps.loadSession(); + assertNoIncompleteExternalComponentActivation(session); if (input.apfInterceptorRequested === true || session?.apfInterceptorRequested === true) { reportUnsupportedApfLifecycle("resume", deps); } From 91420c9dfe4a40abb233fac5c8271c19cb55675f Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 10 Sep 2026 11:34:04 -0700 Subject: [PATCH 7/8] fix(onboard): preserve component lifecycle ordering Signed-off-by: Apurv Kumaria --- .../onboard/docker-driver-gateway-config.ts | 3 +- .../docker-driver-gateway-process-identity.ts | 8 +++ .../docker-driver-gateway-runtime.test.ts | 7 ++- .../machine/initial-flow-phases.test.ts | 50 ++++++++++++++++--- .../onboard/machine/initial-flow-phases.ts | 3 ++ 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/lib/onboard/docker-driver-gateway-config.ts b/src/lib/onboard/docker-driver-gateway-config.ts index aca419d4a5f..b1b19836e12 100644 --- a/src/lib/onboard/docker-driver-gateway-config.ts +++ b/src/lib/onboard/docker-driver-gateway-config.ts @@ -37,6 +37,7 @@ const LEGACY_DOCKER_DRIVER_GATEWAY_JWT_TTL_SECS = 3600; const PRE_AUTH_DOCKER_DRIVER_GATEWAY_VERSION = "0.0.44"; export const NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV = "NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY"; +export const NO_EXTERNAL_COMPONENT_GATEWAY_IDENTITY = "none"; export const NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV = "NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE"; interface FileIdentity { @@ -1063,7 +1064,7 @@ export function prepareDockerDriverGatewayConfigEnv( // An explicit absence lets the existing runtime comparison detect removal. gatewayEnv[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV] = externalComponent ? externalComponentGatewayIdentity(externalComponent) - : "none"; + : NO_EXTERNAL_COMPONENT_GATEWAY_IDENTITY; if (runtime.gatewayConfig.sandboxNamespace === "omitted") { delete gatewayEnv[NEMOCLAW_OPENSHELL_SANDBOX_NAMESPACE_ENV]; } else { diff --git a/src/lib/onboard/docker-driver-gateway-process-identity.ts b/src/lib/onboard/docker-driver-gateway-process-identity.ts index 3e1128581a9..41a3ed1e8fd 100644 --- a/src/lib/onboard/docker-driver-gateway-process-identity.ts +++ b/src/lib/onboard/docker-driver-gateway-process-identity.ts @@ -4,6 +4,10 @@ import fs from "node:fs"; import { resolveGatewayName } from "./gateway-binding"; +import { + NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV, + NO_EXTERNAL_COMPONENT_GATEWAY_IDENTITY, +} from "./docker-driver-gateway-config"; import { gatewayProcessCmdlineMatches, OPENSHELL_GATEWAY_PROCESS_NAMES, @@ -43,6 +47,10 @@ export function readDockerDriverGatewayProcessEnvironment( } catch { return null; } + if (env[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV] === undefined) { + env[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV] = + NO_EXTERNAL_COMPONENT_GATEWAY_IDENTITY; + } return env; } diff --git a/src/lib/onboard/docker-driver-gateway-runtime.test.ts b/src/lib/onboard/docker-driver-gateway-runtime.test.ts index d4a06b74359..f292019936a 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.test.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.test.ts @@ -618,10 +618,13 @@ describe("docker-driver gateway runtime helpers", () => { } }); - it("reuses a systemd-owned gateway without detached cleanup identity (#6903)", () => { + it("reuses a systemd-owned gateway with a legacy unset component identity (#11340)", () => { const pid = 12_350; const gatewayBin = "/usr/bin/openshell-gateway"; - const desiredEnv = { OPENSHELL_DRIVERS: "docker" }; + const desiredEnv = { + OPENSHELL_DRIVERS: "docker", + [NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV]: "none", + }; const { helpers } = makeHelpers({ runCapture: vi.fn(() => gatewayBin), }); diff --git a/src/lib/onboard/machine/initial-flow-phases.test.ts b/src/lib/onboard/machine/initial-flow-phases.test.ts index 9acbd4f7afb..47a1c2bf31e 100644 --- a/src/lib/onboard/machine/initial-flow-phases.test.ts +++ b/src/lib/onboard/machine/initial-flow-phases.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { createSession, type Session } from "../../state/onboard-session"; +import type { PreparedExternalComponent } from "../external-component"; import { resolveGatewayOwner } from "../gateway-ownership"; import { createInitialOnboardFlowPhases, @@ -130,10 +131,15 @@ describe("initial onboard flow phases", () => { const gpu: Gpu = { type: "nvidia", platform: "linux" }; let preflightFailure: Error | null = null; const commitSelectedAgentTransition = vi.fn(async () => createSession()); - const prepareExternalComponent = vi.fn(() => null); + const prepareExternalComponent = vi.fn<() => PreparedExternalComponent | null>(() => null); const runPreflight = vi.fn(async () => preflightFailure ? Promise.reject(preflightFailure) : gpu, ); + const startPreflight = vi.fn(); + const completePreflight = vi.fn(async () => createSession()); + const assertGatewayReadiness = vi.fn(async () => undefined); + const assertExternalComponentFreshSandbox = vi.fn(); + const configureExternalComponentGateway = vi.fn(); const phases = createInitialOnboardFlowPhases({ explicitSandboxGpuFlag: null, sandboxGpuDevice: null, @@ -157,15 +163,15 @@ describe("initial onboard flow phases", () => { validateSandboxGpuPreflight: vi.fn(), skippedStepMessage: vi.fn(), recordStateSkipped: async () => createSession(), - startRecordedStep: vi.fn(), - recordStepComplete: async () => createSession(), + startRecordedStep: startPreflight, + recordStepComplete: completePreflight, updateSession: (mutator) => { const next = createSession(); return mutator(next) ?? next; }, }, getInitialGatewayReuseState: () => "healthy", - assertGatewayReadiness: vi.fn(async () => undefined), + assertGatewayReadiness, prepareExternalComponent, gatewayName: "nemoclaw", recreateSandbox: () => false, @@ -189,8 +195,8 @@ describe("initial onboard flow phases", () => { listenerExecPath: null, listenerSupervisorMatch: null, }), - assertExternalComponentFreshSandbox: vi.fn(), - configureExternalComponentGateway: vi.fn(), + assertExternalComponentFreshSandbox, + configureExternalComponentGateway, refreshDockerDriverGatewayReuseState: async (state) => state, gatewayCliSupportsLifecycleCommands: () => false, verifyGatewayContainerRunning: () => "running", @@ -262,6 +268,38 @@ describe("initial onboard flow phases", () => { "invalid external component declaration", ); expect(runPreflight).not.toHaveBeenCalled(); + + const component: PreparedExternalComponent = { + declaration: { + schemaVersion: 1, + componentId: "policy-governance", + interceptorSocketPath: "/run/user/1000/component/interceptor.sock", + activationSocketPath: "/run/user/1000/component/activation.sock", + }, + revalidateBeforeGateway: vi.fn(), + revalidateBeforeActivation: vi.fn(), + }; + prepareExternalComponent.mockReturnValue(component); + assertExternalComponentFreshSandbox.mockImplementation(() => { + throw new Error("sandbox is not fresh"); + }); + startPreflight.mockClear(); + completePreflight.mockClear(); + runPreflight.mockClear(); + assertGatewayReadiness.mockClear(); + commitSelectedAgentTransition.mockClear(); + configureExternalComponentGateway.mockClear(); + + await expect( + phases[0].run(context({ requestedSandboxName: "component-sandbox" })), + ).rejects.toThrow("sandbox is not fresh"); + expect(assertExternalComponentFreshSandbox).toHaveBeenCalledWith("component-sandbox"); + expect(startPreflight).not.toHaveBeenCalled(); + expect(runPreflight).not.toHaveBeenCalled(); + expect(completePreflight).not.toHaveBeenCalled(); + expect(assertGatewayReadiness).not.toHaveBeenCalled(); + expect(commitSelectedAgentTransition).not.toHaveBeenCalled(); + expect(configureExternalComponentGateway).not.toHaveBeenCalled(); }); it("repairs preflight before strict gateway entry", async () => { diff --git a/src/lib/onboard/machine/initial-flow-phases.ts b/src/lib/onboard/machine/initial-flow-phases.ts index 7c50ea586fb..d55d6bf1575 100644 --- a/src/lib/onboard/machine/initial-flow-phases.ts +++ b/src/lib/onboard/machine/initial-flow-phases.ts @@ -146,6 +146,9 @@ export function createInitialOnboardFlowPhases< if (externalComponent && (context.resume || options.recreateSandbox())) { throw new ExternalComponentContractError("lifecycle_unsupported"); } + if (externalComponent) { + options.gatewayDeps.assertExternalComponentFreshSandbox(context.requestedSandboxName); + } const preflightResult = await handlePreflightState({ resume: context.resume, session: context.session, From a7b7a1746aeaffb52cb0a5d833ec08da02bd0156 Mon Sep 17 00:00:00 2001 From: Apurv Kumaria Date: Thu, 10 Sep 2026 11:47:15 -0700 Subject: [PATCH 8/8] chore(format): apply current source format Signed-off-by: Apurv Kumaria --- src/lib/onboard/docker-driver-gateway-env.test.ts | 11 ++++------- .../onboard/docker-driver-gateway-process-identity.ts | 3 +-- test/onboarding/onboard-fsm-live-slices.test.ts | 7 +++---- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/lib/onboard/docker-driver-gateway-env.test.ts b/src/lib/onboard/docker-driver-gateway-env.test.ts index 27e4363c58f..8c7f2883895 100644 --- a/src/lib/onboard/docker-driver-gateway-env.test.ts +++ b/src/lib/onboard/docker-driver-gateway-env.test.ts @@ -51,13 +51,10 @@ describe("buildDockerDriverGatewayEnv", () => { resolveSandboxBin: () => "/usr/bin/openshell-sandbox", }); - configureDockerDriverGatewayExternalComponent( - env, - { - componentId: "policy-governance", - interceptorSocketPath: "/run/user/1000/component/interceptor.sock", - }, - ); + configureDockerDriverGatewayExternalComponent(env, { + componentId: "policy-governance", + interceptorSocketPath: "/run/user/1000/component/interceptor.sock", + }); expect(fs.readFileSync(env.OPENSHELL_GATEWAY_CONFIG, "utf8")).toContain( 'name = "policy-governance"', diff --git a/src/lib/onboard/docker-driver-gateway-process-identity.ts b/src/lib/onboard/docker-driver-gateway-process-identity.ts index 41a3ed1e8fd..a5bbef24d70 100644 --- a/src/lib/onboard/docker-driver-gateway-process-identity.ts +++ b/src/lib/onboard/docker-driver-gateway-process-identity.ts @@ -48,8 +48,7 @@ export function readDockerDriverGatewayProcessEnvironment( return null; } if (env[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV] === undefined) { - env[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV] = - NO_EXTERNAL_COMPONENT_GATEWAY_IDENTITY; + env[NEMOCLAW_EXTERNAL_COMPONENT_GATEWAY_IDENTITY_ENV] = NO_EXTERNAL_COMPONENT_GATEWAY_IDENTITY; } return env; } diff --git a/test/onboarding/onboard-fsm-live-slices.test.ts b/test/onboarding/onboard-fsm-live-slices.test.ts index a99d52ea907..89cd2b20c42 100644 --- a/test/onboarding/onboard-fsm-live-slices.test.ts +++ b/test/onboarding/onboard-fsm-live-slices.test.ts @@ -688,10 +688,9 @@ describe("live onboard FSM slice boundaries", () => { }); it("rejects a registered component with providerless APF before effects (#11340)", () => { - assert.deepEqual( - runSliceProbe({ slice: "initial", mode: "providerless-external-component" }), - ["component-validated"], - ); + assert.deepEqual(runSliceProbe({ slice: "initial", mode: "providerless-external-component" }), [ + "component-validated", + ]); }); it("rechecks retained sandbox admission after acquiring the onboarding lock (#9833)", () => {