Skip to content
111 changes: 5 additions & 106 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ const {
buildDirectSandboxGpuProofCommands,
prepareInitialSandboxCreatePolicy,
}: typeof import("./onboard/initial-policy") = require("./onboard/initial-policy");
const {
getSelectionDrift,
}: typeof import("./onboard/selection-drift") = require("./onboard/selection-drift");
const crypto = require("node:crypto");
const fs = require("fs");
const os = require("os");
Expand Down Expand Up @@ -342,6 +345,7 @@ import type { TierDefinition, TierPreset } from "./policy/tiers";
import type { SandboxCreateFailure, ValidationClassification } from "./validation";
import type { ProbeRecovery } from "./validation-recovery";
import type { WebSearchConfig } from "./inference/web-search";
import type { SelectionDrift } from "./onboard/selection-drift";

const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1";
const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY;
Expand Down Expand Up @@ -2091,15 +2095,6 @@ type EndpointValidationResult =
| { ok: true; api: string | null; retry?: undefined }
| { ok: false; retry: "credential" | "selection" | "retry" | "model"; api?: undefined };

type SelectionDrift = {
changed: boolean;
providerChanged: boolean;
modelChanged: boolean;
existingProvider: string | null;
existingModel: string | null;
unknown: boolean;
};

function verifyDirectSandboxGpu(sandboxName: string): void {
console.log(" Verifying direct sandbox GPU access...");
for (const proof of buildDirectSandboxGpuProofCommands(sandboxName)) {
Expand Down Expand Up @@ -2354,102 +2349,6 @@ function pruneStaleSandboxEntry(sandboxName: string): boolean {
return liveExists;
}

function findSelectionConfigPath(dir: string): string | null {
if (!dir || !fs.existsSync(dir)) return null;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = findSelectionConfigPath(fullPath);
if (found) return found;
continue;
}
if (entry.name === "config.json") {
return fullPath;
}
}
return null;
}

function readSandboxSelectionConfig(sandboxName: string): ProviderSelectionConfig | null {
if (!sandboxName) return null;
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-selection-"));
try {
const result = runOpenshell(
[
"sandbox",
"download",
sandboxName,
"/sandbox/.nemoclaw/config.json",
`${tmpDir}${path.sep}`,
],
{ ignoreError: true, stdio: ["ignore", "ignore", "ignore"] },
);
if (result.status !== 0) return null;
const configPath = findSelectionConfigPath(tmpDir);
if (!configPath) return null;
try {
const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
} catch {
return null;
} finally {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
}

function getSelectionDrift(
sandboxName: string,
requestedProvider: string | null,
requestedModel: string | null,
): SelectionDrift {
const existing = readSandboxSelectionConfig(sandboxName);
if (!existing) {
return {
changed: true,
providerChanged: false,
modelChanged: false,
existingProvider: null,
existingModel: null,
unknown: true,
};
}

const existingProvider = typeof existing.provider === "string" ? existing.provider : null;
const existingModel = typeof existing.model === "string" ? existing.model : null;
if (!existingProvider || !existingModel) {
return {
changed: true,
providerChanged: false,
modelChanged: false,
existingProvider,
existingModel,
unknown: true,
};
}

const providerChanged = Boolean(
existingProvider && requestedProvider && existingProvider !== requestedProvider,
);
const modelChanged = Boolean(existingModel && requestedModel && existingModel !== requestedModel);

return {
changed: providerChanged || modelChanged,
providerChanged,
modelChanged,
existingProvider,
existingModel,
unknown: false,
};
}

async function confirmRecreateForSelectionDrift(
sandboxName: string,
drift: SelectionDrift,
Expand Down Expand Up @@ -6085,7 +5984,7 @@ async function createSandbox(
const needsProviderMigration =
hasMessagingTokens &&
messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name));
const selectionDrift = getSelectionDrift(sandboxName, provider, model);
const selectionDrift = getSelectionDrift(sandboxName, provider, model, { runOpenshell });
const confirmedSelectionDrift = selectionDrift.changed && !selectionDrift.unknown;
const sandboxGpuDrift = hasSandboxGpuDrift(sandboxName, effectiveSandboxGpuConfig);

Expand Down
115 changes: 115 additions & 0 deletions src/lib/onboard/selection-drift.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// 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, describe, expect, it, vi } from "vitest";

import {
findSelectionConfigPath,
getSelectionDrift,
readSandboxSelectionConfig,
} from "./selection-drift";

const tmpRoots: string[] = [];

function tmpRoot(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-selection-test-"));
tmpRoots.push(dir);
return dir;
}

afterEach(() => {
for (const dir of tmpRoots.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});

describe("selection drift helpers", () => {
it("finds nested config.json files", () => {
const root = tmpRoot();
const nested = path.join(root, "sandbox", ".nemoclaw");
fs.mkdirSync(nested, { recursive: true });
const configPath = path.join(nested, "config.json");
fs.writeFileSync(configPath, "{}", "utf-8");

expect(findSelectionConfigPath(root)).toBe(configPath);
});

it("returns null when the sandbox download fails", () => {
const runOpenshell = vi.fn(() => ({ status: 1 }));

expect(readSandboxSelectionConfig("alpha", { runOpenshell })).toBeNull();
expect(runOpenshell).toHaveBeenCalledWith(
[
"sandbox",
"download",
"alpha",
"/sandbox/.nemoclaw/config.json",
expect.stringMatching(/nemoclaw-selection-.*\/$/),
],
{ ignoreError: true, stdio: ["ignore", "ignore", "ignore"] },
);
});

it("reads a downloaded selection config and cleans up the temp directory", () => {
let downloadedParent: string | null = null;
const runOpenshell = vi.fn((args: string[]) => {
downloadedParent = args[4];
const targetDir = path.join(String(downloadedParent), "nested");
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(
path.join(targetDir, "config.json"),
JSON.stringify({ provider: "compatible-endpoint", model: "model-a" }),
"utf-8",
);
return { status: 0 };
});

expect(readSandboxSelectionConfig("alpha", { runOpenshell })).toEqual({
provider: "compatible-endpoint",
model: "model-a",
});
expect(downloadedParent).not.toBeNull();
expect(fs.existsSync(String(downloadedParent))).toBe(false);
});

it("reports unknown drift when no readable selection config exists", () => {
expect(
getSelectionDrift("alpha", "compatible-endpoint", "model-a", {
runOpenshell: () => ({ status: 1 }),
}),
).toEqual({
changed: true,
providerChanged: false,
modelChanged: false,
existingProvider: null,
existingModel: null,
unknown: true,
});
});

it("reports provider and model drift from the downloaded selection config", () => {
const runOpenshell = vi.fn((args: string[]) => {
const targetDir = String(args[4]);
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(
path.join(targetDir, "config.json"),
JSON.stringify({ provider: "old-provider", model: "old-model" }),
"utf-8",
);
return { status: 0 };
});

expect(getSelectionDrift("alpha", "new-provider", "new-model", { runOpenshell })).toEqual({
changed: true,
providerChanged: true,
modelChanged: true,
existingProvider: "old-provider",
existingModel: "old-model",
unknown: false,
});
});
});
127 changes: 127 additions & 0 deletions src/lib/onboard/selection-drift.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// 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 type { ProviderSelectionConfig } from "../inference/config";

export type SelectionDrift = {
changed: boolean;
providerChanged: boolean;
modelChanged: boolean;
existingProvider: string | null;
existingModel: string | null;
unknown: boolean;
};

type RunOpenshellForSelection = (
args: string[],
opts: { ignoreError: true; stdio: ["ignore", "ignore", "ignore"] },
) => { status: number | null };

export type SelectionConfigReadDeps = {
runOpenshell: RunOpenshellForSelection;
tmpDir?: string;
};

export function findSelectionConfigPath(dir: string): string | null {
if (!dir || !fs.existsSync(dir)) return null;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const found = findSelectionConfigPath(fullPath);
if (found) return found;
continue;
}
if (entry.name === "config.json") {
return fullPath;
}
}
return null;
}

export function readSandboxSelectionConfig(
sandboxName: string,
deps: SelectionConfigReadDeps,
): ProviderSelectionConfig | null {
if (!sandboxName) return null;
const tmpDir = fs.mkdtempSync(path.join(deps.tmpDir ?? os.tmpdir(), "nemoclaw-selection-"));
try {
const result = deps.runOpenshell(
[
"sandbox",
"download",
sandboxName,
"/sandbox/.nemoclaw/config.json",
`${tmpDir}${path.sep}`,
],
{ ignoreError: true, stdio: ["ignore", "ignore", "ignore"] },
);
if (result.status !== 0) return null;
const configPath = findSelectionConfigPath(tmpDir);
if (!configPath) return null;
try {
const parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
} catch {
return null;
} finally {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
}

export function getSelectionDrift(
sandboxName: string,
requestedProvider: string | null,
requestedModel: string | null,
deps: SelectionConfigReadDeps,
): SelectionDrift {
const existing = readSandboxSelectionConfig(sandboxName, deps);
if (!existing) {
return {
changed: true,
providerChanged: false,
modelChanged: false,
existingProvider: null,
existingModel: null,
unknown: true,
};
}

const existingProvider = typeof existing.provider === "string" ? existing.provider : null;
const existingModel = typeof existing.model === "string" ? existing.model : null;
if (!existingProvider || !existingModel) {
return {
changed: true,
providerChanged: false,
modelChanged: false,
existingProvider,
existingModel,
unknown: true,
};
}

const providerChanged = Boolean(
existingProvider && requestedProvider && existingProvider !== requestedProvider,
);
const modelChanged = Boolean(existingModel && requestedModel && existingModel !== requestedModel);

return {
changed: providerChanged || modelChanged,
providerChanged,
modelChanged,
existingProvider,
existingModel,
unknown: false,
};
}
Loading
Loading