Skip to content
4 changes: 2 additions & 2 deletions .github/workflows/managed-images.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ jobs:
CANDIDATE_SHA: ${{ github.event.pull_request.head.sha }}
# Retains the reviewed discovery-permission repair and the current
# managed-image security inventory. The previous staging source pinned
# Vim 9.2.0782, which cannot satisfy the candidate's 9.2.0858 contract.
STAGING_QA_SOURCE_SHA: af2a73f0d6ce8f08a2975560f376470387c535d0
# libssh2 nemoclaw1, which cannot satisfy the candidate's nemoclaw2 contract.
STAGING_QA_SOURCE_SHA: ce96811ddb418ad01c040521a1fe912b5bcb405e
STAGING_QA_BASE_IMAGE: nemoclaw-deepagents-code-base:staging-31396519688
STAGING_QA_FINAL_IMAGE: nemoclaw-managed-pr/langchain-deepagents-code-staging-qa
steps:
Expand Down
101 changes: 46 additions & 55 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -912,45 +912,6 @@ function persistMigratedLegacyKeys(): void {
}
}

// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
function upsertProvider(name: string, type: string, credentialEnv: string, baseUrl: string | null, env: NodeJS.ProcessEnv = {}, gatewayName: string = GATEWAY_NAME) {
const result = onboardProviders.upsertProvider(
name,
type,
credentialEnv,
baseUrl,
env,
setupInferenceFactory.createGatewayScopedOpenshellRunner(runOpenshell, gatewayName),
);
if (result.ok && credentialEnv) {
const stagedValue = stagedLegacyValues.get(credentialEnv);
if (stagedValue !== undefined) {
// openshell receives `--credential <ENV>` and reads the value from the
// `env` block passed here, falling back to the inherited process.env.
// Use getCredential() for the env-fallback branch (per the
// direct credential env guard from PR #2306) — it mirrors
// openshell's resolution order while the staging contract has
// already populated the same value into process.env.
const upsertedValue = env[credentialEnv] ?? getCredential(credentialEnv);
if (upsertedValue === stagedValue) {
// The gateway received the staged legacy value verbatim — count
// this key as migrated.
migratedLegacyKeys.add(credentialEnv);
} else {
// A later upsert under the same env-key wrote a different value
// (e.g. a retry-loop after validation failure replaced the legacy
// key with a freshly entered one, or a placeholder like "dummy"
// for vllm-local). The gateway no longer holds the staged legacy
// value under this env-key, so withdraw the migration mark — the
// cleanup gate must keep the legacy file intact.
migratedLegacyKeys.delete(credentialEnv);
}
persistMigratedLegacyKeys();
}
}
return result;
}

type MessagingTokenDef = import("./onboard/messaging-prep").MessagingTokenDef;

type EndpointValidationResult =
Expand All @@ -969,19 +930,27 @@ const registeredCredentialProviders =
runOpenshell,
redact,
getGatewayName: () => GATEWAY_NAME,
getCredential,
normalizeCredentialValue,
updateSession: onboardSession.updateSession,
stagedLegacyValues,
migratedLegacyKeys,
persistMigratedLegacyKeys,
});
const { upsertMessagingProviders, providerMatchesGatewayCredential } =
const { upsertProvider, upsertMessagingProviders, providerMatchesGatewayCredential } =
registeredCredentialProviders;
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const providerExistsInGateway = (name: string, gatewayName: string = GATEWAY_NAME) => onboardProviders.providerExistsInGateway(name, setupInferenceFactory.createGatewayScopedOpenshellRunner(runOpenshell, gatewayName));
const providerExistsInGateway = (name: string, gatewayName: string = GATEWAY_NAME) =>
onboardProviders.providerExistsInGateway(
name,
setupInferenceFactory.createGatewayScopedOpenshellRunner(runOpenshell, gatewayName),
);

// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const { verifyInferenceRoute, isInferenceRouteReady, checkGatewayRouteCompatibility, preflightGatewayRouteDiscovery } = inferenceRouteHelpers.createInferenceRouteHelpers(runCaptureOpenshell);
const {
verifyInferenceRoute,
isInferenceRouteReady,
checkGatewayRouteCompatibility,
preflightGatewayRouteDiscovery,
} = inferenceRouteHelpers.createInferenceRouteHelpers(runCaptureOpenshell);
const { inspectSandboxForCreate, confirmRecreateForSelectionDrift, isOpenclawReady } =
sandboxLifecycle.createSandboxLifecycleHelpers({
runCaptureOpenshell,
Expand Down Expand Up @@ -1070,11 +1039,24 @@ const {
assertOllamaUpgradeApplied,
});

// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const handleVllmSelection = createSetupNimVllmHandler({
VLLM_PORT, runCapture, getLocalProviderBaseUrl, getLocalProviderValidationBaseUrl,
getManagedVllmProviderBinding: localInference.getManagedVllmProviderBinding, queryVllmModels: (baseUrl, apiKey) => { const result = localInference.probeVllmModels(baseUrl, apiKey); return result.ok ? result.body : ""; }, isSafeModelId, requireValue, validateOpenAiLikeSelection,
applyVllmRuntimeContextWindow: localInference.applyVllmRuntimeContextWindow, isDgxSparkHost: () => nim.detectNvidiaPlatform() === "spark", isNemoClawManagedVllmRunning: vllmInference.isNemoClawManagedVllmRunning, persistConfiguredManagedVllmRuntimeReceipt: vllmInference.persistConfiguredManagedVllmRuntimeReceipt,
VLLM_PORT,
runCapture,
getLocalProviderBaseUrl,
getLocalProviderValidationBaseUrl,
getManagedVllmProviderBinding: localInference.getManagedVllmProviderBinding,
queryVllmModels: (baseUrl, apiKey) => {
const result = localInference.probeVllmModels(baseUrl, apiKey);
return result.ok ? result.body : "";
},
isSafeModelId,
requireValue,
validateOpenAiLikeSelection,
applyVllmRuntimeContextWindow: localInference.applyVllmRuntimeContextWindow,
isDgxSparkHost: () => nim.detectNvidiaPlatform() === "spark",
isNemoClawManagedVllmRunning: vllmInference.isNemoClawManagedVllmRunning,
persistConfiguredManagedVllmRuntimeReceipt:
vllmInference.persistConfiguredManagedVllmRuntimeReceipt,
exitProcess: (code) => process.exit(code),
});
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
Expand Down Expand Up @@ -3893,9 +3875,12 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
const gatewaySandboxName = resume
? (recordedSandboxName ?? requestedSandboxName ?? checkpointedSandboxName)
: null;
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const onboardGateway = gatewayBinding.resolveCoreOnboardGatewayBinding({ authoritativeGateway, currentGateway: { name: GATEWAY_NAME, port: GATEWAY_PORT }, resume, sandbox: gatewaySandboxName ? registry.getSandbox(gatewaySandboxName) : null });
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const onboardGateway = gatewayBinding.resolveCoreOnboardGatewayBinding({
authoritativeGateway,
currentGateway: { name: GATEWAY_NAME, port: GATEWAY_PORT },
resume,
sandbox: gatewaySandboxName ? registry.getSandbox(gatewaySandboxName) : null,
});
({ name: GATEWAY_NAME, port: GATEWAY_PORT } = onboardGateway);
process.env.OPENSHELL_GATEWAY = GATEWAY_NAME;
const resolvedGatewayOwner = getGatewayOwner();
Expand Down Expand Up @@ -4051,8 +4036,10 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
selectedMessagingChannels,
assertSandboxNameAllowed: onboardEntryOptions.assertDefaultSandboxNameAllowed,
});
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const runCoreGatewayOpenshell = setupInferenceFactory.createGatewayScopedOpenshellRunner(runOpenshell, GATEWAY_NAME);
const runCoreGatewayOpenshell = setupInferenceFactory.createGatewayScopedOpenshellRunner(
runOpenshell,
GATEWAY_NAME,
);
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
const endpointProvenance = { endpointSource: opts.endpointSource, endpointSourceProvider: opts.rebuildRegistryInferenceRoute?.route.provider ?? null, endpointSourceEndpointUrl: opts.rebuildRegistryInferenceRoute?.route.endpointUrl ?? null, getSandboxRegistryEntry: registry.getSandbox };
const providerReviewDeps = setupInferenceFactory.createDefaultProviderReviewDeps(
Expand Down Expand Up @@ -4282,8 +4269,12 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
loadSession: onboardSession.loadSession,
getActiveSandbox: (name) => registry.getSandbox(name),
mergePolicyMessagingChannels,
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
verifyCompatibleEndpointSandboxSmoke: (options) => verifyCompatibleEndpointSandboxSmoke({ ...options, runOpenshell: runCoreGatewayOpenshell, redact }),
verifyCompatibleEndpointSandboxSmoke: (options) =>
verifyCompatibleEndpointSandboxSmoke({
...options,
runOpenshell: runCoreGatewayOpenshell,
redact,
}),
preparePolicyPresetResumeSelection,
arePolicyPresetsApplied,
skippedStepMessage,
Expand Down
75 changes: 75 additions & 0 deletions src/lib/onboard/credential-provider-registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function registrationDeps(
runOpenshell: runOpenshellMock as unknown as CredentialProviderRegistrationDeps["runOpenshell"],
redact: (input) => input,
getGatewayName: () => "test-gateway",
getCredential: () => null,
normalizeCredentialValue: (value) => (typeof value === "string" ? value.trim() : ""),
updateSession,
stagedLegacyValues: new Map(),
Expand Down Expand Up @@ -69,6 +70,80 @@ function sandboxInput(bindings: ReturnType<typeof requiredBindings>) {
}

describe("credential provider registration", () => {
it.each([
{
condition: "the explicit environment contains the staged value",
env: { COMPATIBLE_API_KEY: "legacy-key" },
ambientValue: "other-key",
expectedMigrated: true,
},
{
condition: "the inherited environment contains the staged value",
env: {},
ambientValue: "legacy-key",
expectedMigrated: true,
},
{
condition: "the provider receives a replacement value",
env: { COMPATIBLE_API_KEY: "replacement-key" },
ambientValue: "legacy-key",
expectedMigrated: false,
},
])("records migration according to the value sent when $condition", ({
env,
ambientValue,
expectedMigrated,
}) => {
const session = { stagedCredentialProviders: [] } as unknown as Session;
const runOpenshell = vi.fn(() => ({ status: 0, stdout: "", stderr: "" }));
const deps = registrationDeps(runOpenshell, session);
deps.getCredential = vi.fn(() => ambientValue);
deps.stagedLegacyValues = new Map([["COMPATIBLE_API_KEY", "legacy-key"]]);
deps.migratedLegacyKeys.add("COMPATIBLE_API_KEY");
const registration = createCredentialProviderRegistration(deps);

const result = registration.upsertProvider(
"compatible-endpoint",
"openai",
"COMPATIBLE_API_KEY",
"https://inference.example.com/v1",
env,
"alternate-gateway",
);

expect(result).toEqual({ ok: true });
expect(deps.migratedLegacyKeys.has("COMPATIBLE_API_KEY")).toBe(expectedMigrated);
expect(deps.persistMigratedLegacyKeys).toHaveBeenCalledOnce();
expect(runOpenshell).toHaveBeenCalledWith(
expect.arrayContaining(["-g", "alternate-gateway"]),
expect.any(Object),
);
});

it("does not record migration when provider registration fails", () => {
const session = { stagedCredentialProviders: [] } as unknown as Session;
const runOpenshell = vi.fn((args: string[]) => ({
status: args[1] === "get" ? 1 : 9,
stdout: "",
stderr: "registration failed",
}));
const deps = registrationDeps(runOpenshell, session);
deps.stagedLegacyValues = new Map([["COMPATIBLE_API_KEY", "legacy-key"]]);
const registration = createCredentialProviderRegistration(deps);

const result = registration.upsertProvider(
"compatible-endpoint",
"openai",
"COMPATIBLE_API_KEY",
"https://inference.example.com/v1",
{ COMPATIBLE_API_KEY: "legacy-key" },
);

expect(result.ok).toBe(false);
expect(deps.migratedLegacyKeys).toEqual(new Set());
expect(deps.persistMigratedLegacyKeys).not.toHaveBeenCalled();
});

it("updates exact Brave and messaging providers and records secret-free receipts (#6743)", async () => {
const session = { stagedCredentialProviders: [] } as unknown as Session;
const commandResults = new Map([
Expand Down
37 changes: 35 additions & 2 deletions src/lib/onboard/credential-provider-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface CredentialProviderRegistrationDeps {
runOpenshell: OpenshellCliHelpers["runOpenshell"];
redact(input: string): string;
getGatewayName(): string;
getCredential(name: string): string | null;
normalizeCredentialValue(value: unknown): string;
updateSession(mutator: (session: Session) => Session | void): Session;
stagedLegacyValues: ReadonlyMap<string, string>;
Expand Down Expand Up @@ -127,8 +128,8 @@ function validatePlannedCredentialProviderBindings(
}

export function createCredentialProviderRegistration(deps: CredentialProviderRegistrationDeps) {
const gatewayRunner = () =>
createGatewayScopedOpenshellRunner(deps.runOpenshell, deps.getGatewayName());
const gatewayRunner = (gatewayName = deps.getGatewayName()) =>
createGatewayScopedOpenshellRunner(deps.runOpenshell, gatewayName);
const ensureWebSearchProviderProfiles = (
tokenDefs: readonly MessagingTokenDef[],
runOpenshell: OpenshellCliHelpers["runOpenshell"] = deps.runOpenshell,
Expand All @@ -139,6 +140,37 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg
redact: deps.redact,
});

function upsertProvider(
name: string,
type: string,
credentialEnv: string,
baseUrl: string | null,
env: NodeJS.ProcessEnv = {},
gatewayName = deps.getGatewayName(),
) {
const result = providers.upsertProvider(
name,
type,
credentialEnv,
baseUrl,
env,
gatewayRunner(gatewayName),
);
if (result.ok && credentialEnv) {
const stagedValue = deps.stagedLegacyValues.get(credentialEnv);
if (stagedValue !== undefined) {
const upsertedValue = env[credentialEnv] ?? deps.getCredential(credentialEnv);
if (upsertedValue === stagedValue) {
deps.migratedLegacyKeys.add(credentialEnv);
} else {
deps.migratedLegacyKeys.delete(credentialEnv);
}
deps.persistMigratedLegacyKeys();
}
}
return result;
}

function upsertMessagingProviders(
tokenDefs: MessagingTokenDef[],
options: { replaceExisting?: boolean } = {},
Expand Down Expand Up @@ -230,6 +262,7 @@ export function createCredentialProviderRegistration(deps: CredentialProviderReg
return {
providerMatchesGatewayCredential,
stageSandboxCredentialProviders,
upsertProvider,
upsertMessagingProviders,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ function realStageSandboxCredentialProviders(
runOpenshell: runOpenshell as unknown as CredentialProviderRegistrationDeps["runOpenshell"],
redact: (input) => input,
getGatewayName: () => "nemoclaw",
getCredential: () => null,
normalizeCredentialValue: (value) => (typeof value === "string" ? value.trim() : ""),
updateSession: (mutator) => (mutator(registrationSession) ?? registrationSession) as Session,
stagedLegacyValues: new Map(),
Expand Down
8 changes: 4 additions & 4 deletions src/lib/shields/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4727,16 +4727,16 @@ function startFreshShieldsDownTimer(input: {

function completeInterruptedShieldsDown(
sandboxName: string,
opts: ShieldsDownOpts,
state: LoadedShieldsState,
retainedProviderTarget: AgentConfigTarget | null,
opts: ShieldsDownOpts,
): boolean {
if (!state.shieldsDown) return false;

// Provider release deliberately precedes route convergence and the final
// timer-bound transition commit. A process can therefore die after the
// durable provider claim is gone while the host transition remains in
// preparing. Treat that marker as recovery authority too: verify (or
// durable provider claim is gone while the exact host transition remains
// in preparing. Treat that marker as recovery authority too: verify (or
// repair) mutable posture, converge the route, then commit it active.
const completionTarget =
retainedProviderTarget ??
Expand Down Expand Up @@ -4847,7 +4847,7 @@ function shieldsDownWithoutHostLock(sandboxName: string, opts: ShieldsDownOpts =
recoveredProviderTarget = retainedProviderTarget;
}
const initialMode = deriveShieldsMode(state, state._hasStateFile);
if (completeInterruptedShieldsDown(sandboxName, opts, state, retainedProviderTarget)) return;
if (completeInterruptedShieldsDown(sandboxName, state, retainedProviderTarget, opts)) return;

const timeoutSeconds = parseDuration(opts.timeout || `${DEFAULT_TIMEOUT_SECONDS}`);
const reason = opts.reason || null;
Expand Down
1 change: 1 addition & 0 deletions test/credential-migration-reconciliation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ describe("legacy credential reconciliation", () => {
runOpenshell as unknown as CredentialProviderRegistrationDeps["runOpenshell"],
redact: (input) => input,
getGatewayName: () => "nemoclaw",
getCredential: (name) => process.env[name] ?? null,
normalizeCredentialValue: (value) => (typeof value === "string" ? value.trim() : ""),
updateSession: (mutator) => mutator(session) ?? session,
stagedLegacyValues,
Expand Down
Loading
Loading