Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b8204d2
fix(onboard): probe registered extra providers exactly
apurvvkumaria Jul 9, 2026
c66486b
test(onboard): keep provider probe fixtures linear
apurvvkumaria Jul 9, 2026
a55ce25
test(onboard): remove obsolete provider-list mock
apurvvkumaria Jul 9, 2026
7ebcab1
fix(onboard): recognize wrapped provider errors
apurvvkumaria Jul 9, 2026
a0ea6e6
fix(onboard): handle real probe output safely
apurvvkumaria Jul 9, 2026
cb7977f
fix(onboard): surface indeterminate provider probes
apurvvkumaria Jul 9, 2026
b23bfea
test(onboard): cover stale extra providers on resume repair
prekshivyas Jul 10, 2026
c0b2d16
Merge remote-tracking branch 'origin/main' into codex/6501-strict-ext…
prekshivyas Jul 10, 2026
f2516d3
test(onboard): avoid repair conditional growth
prekshivyas Jul 10, 2026
d6b1248
merge(main): sync v0.0.80 branch
cv Jul 10, 2026
ca48d51
Merge branch 'main' into codex/6501-strict-extra-provider-reconciliation
prekshivyas Jul 10, 2026
5bc41db
Merge remote branch 'codex/6501-strict-extra-provider-reconciliation'
prekshivyas Jul 10, 2026
043cfcd
test(onboard): cover resume stale provider reconciliation
prekshivyas Jul 10, 2026
e32a26c
fix(onboard): parse wrapped provider diagnostics
prekshivyas Jul 10, 2026
4fd6a33
fix(onboard): harden extra provider reconciliation
prekshivyas Jul 10, 2026
d458c26
fix(onboard): prune stale extra providers
prekshivyas Jul 10, 2026
14d1fc8
fix(onboard): honor resume sandbox recreation
prekshivyas Jul 10, 2026
78fa183
chore(onboard): keep entrypoint size neutral
prekshivyas Jul 10, 2026
d6adbfd
test(onboard): split resume recreation coverage
prekshivyas Jul 10, 2026
85d092d
fix(onboard): parse issue-form provider diagnostics
prekshivyas Jul 10, 2026
81668f2
fix(onboard): parse provider get stale diagnostics
prekshivyas Jul 10, 2026
f4f7be4
fix(onboard): parse structured provider not-found diagnostics
prekshivyas Jul 10, 2026
a52b5ac
fix(onboard): reconcile extra providers in machine create
prekshivyas Jul 10, 2026
04e17df
chore(onboard): keep create wiring net-neutral
prekshivyas Jul 10, 2026
d1b7085
Merge remote-tracking branch 'origin/main' into codex/6501-strict-ext…
prekshivyas Jul 10, 2026
7e07ab3
test(e2e): record onboard mock parity
prekshivyas Jul 10, 2026
7ea6dcd
fix(onboard): preserve empty reconciled extra providers
prekshivyas Jul 10, 2026
5671f09
test(e2e): prove extra provider resume filtering
prekshivyas Jul 10, 2026
974ac86
fix(onboard): accept wrapped provider not-found diagnostics
prekshivyas Jul 10, 2026
ed92e4c
Merge remote-tracking branch 'origin/main' into codex/6501-strict-ext…
prekshivyas Jul 10, 2026
d0fbf2d
fix(onboard): prune targeted missing extra providers
prekshivyas Jul 10, 2026
e69a6a0
test(sandbox): budget MCP status subprocess suite
prekshivyas Jul 10, 2026
1a6885b
test(onboard): cover extra provider retry stability
prekshivyas Jul 10, 2026
cb0a4c8
test(onboard): isolate gateway recovery coredns patch
prekshivyas Jul 10, 2026
bc4a194
test(policy): isolate policy list docker guidance
prekshivyas Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ ${body}
return { status: result.status, stdout: result.stdout };
}

describe("MCP status wire-level credential-resolution probe", () => {
describe("MCP status wire-level credential-resolution probe", { timeout: 15_000 }, () => {
it("probes by default for a single named server and surfaces the wire failure (#6379)", () => {
const home = createTempHome("nemoclaw-mcp-resolution-single-");
const { stdout } = runHarness(
Expand Down
9 changes: 9 additions & 0 deletions src/lib/actions/sandbox/policy-channel-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ const moduleMocks = vi.hoisted(() => ({
listCustomPresets: vi.fn<(sandboxName: string) => PresetInfo[]>(),
getAppliedPresets: vi.fn<(sandboxName: string) => string[]>(),
getGatewayPresets: vi.fn<(sandboxName: string) => string[] | null>(),
isDockerRuntimeDown: vi.fn<(sandboxName: string) => boolean>(),
printDockerRuntimeDownGuidance: vi.fn(),
}));

vi.mock("../../state/registry", async (importOriginal) => ({
Expand All @@ -31,6 +33,12 @@ vi.mock("../../policy", async (importOriginal) => ({
getGatewayPresets: moduleMocks.getGatewayPresets,
}));

vi.mock("./gateway-failure-classifier", async (importOriginal) => ({
...(await importOriginal<typeof import("./gateway-failure-classifier")>()),
isDockerRuntimeDown: moduleMocks.isDockerRuntimeDown,
printDockerRuntimeDownGuidance: moduleMocks.printDockerRuntimeDownGuidance,
}));

import { listSandboxPolicies } from "./policy-channel";

const POLICY_PRESETS: PresetInfo[] = [
Expand Down Expand Up @@ -78,6 +86,7 @@ beforeEach(() => {
moduleMocks.getCustomPolicies.mockReturnValue([]);
moduleMocks.listPresets.mockReturnValue(POLICY_PRESETS);
moduleMocks.listCustomPresets.mockReturnValue([]);
moduleMocks.isDockerRuntimeDown.mockReturnValue(false);
});

afterEach(() => {
Expand Down
10 changes: 5 additions & 5 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
// Interactive onboarding wizard — 8 steps from zero to running sandbox.
// Supports non-interactive mode via --non-interactive flag or
// NEMOCLAW_NON_INTERACTIVE=1 env var for CI/CD pipelines.

const {
envInt,
LOCAL_INFERENCE_TIMEOUT_SECS,
Expand Down Expand Up @@ -166,7 +165,6 @@ const fs = require("fs");
const os = require("os");
const path = require("path");
const pRetry = require("p-retry");

const runner: typeof import("./runner") = require("./runner");
const { ROOT, SCRIPTS, redact, run, runCapture, runCaptureEx, runFile, validateName } = runner;
const braveProviderProfile: typeof import("./onboard/brave-provider-profile") = require("./onboard/brave-provider-profile");
Expand Down Expand Up @@ -2300,7 +2298,6 @@ async function createSandboxWithBaseImageResolution(
preparedBuildContext: PreparedSandboxBuildContext | null = null,
) {
step(6, 8, "Creating sandbox");

const sandboxName = validateName(
sandboxNameOverride ?? (await promptValidatedSandboxName(agent)),
"sandbox name",
Expand Down Expand Up @@ -2334,7 +2331,6 @@ async function createSandboxWithBaseImageResolution(
getApiForwardPort: () => getDashboardForwardPort(chatUiUrl),
});
const hermesDashboardState = hermesDashboardForwarding.resolveStateForPort(effectivePort);

const {
messagingTokenDefs,
extraPlaceholderKeys,
Expand Down Expand Up @@ -2746,7 +2742,8 @@ async function createSandboxWithBaseImageResolution(
messagingTokenDefs,
reusableMessagingChannels,
reusableMessagingProviders,
extraProviders: reconcileRegisteredExtraProviders(GATEWAY_NAME, { runOpenshell }),
// biome-ignore format: keep src/lib/onboard.ts net-neutral for growth guardrail.
extraProviders: createIntent?.extraProviders ?? reconcileRegisteredExtraProviders(GATEWAY_NAME, { runOpenshell }),
hermesToolGateways,
sandboxGpuConfig: effectiveSandboxGpuConfig,
dockerDriverGateway,
Expand Down Expand Up @@ -4510,6 +4507,7 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
requestedDcodeAutoApprovalMode: runtimeControlRequests.requestedDcodeAutoApprovalMode,
authoritativePolicyTier:
opts.authoritativeResumeConfig === true ? (opts.policyTier ?? null) : null,
recreateSandbox: isRecreateSandbox,
controlUiPort: _preflightDashboardPort,
rootDir: ROOT,
},
Expand Down Expand Up @@ -4551,6 +4549,8 @@ async function runOnboard(opts: OnboardOptions = {}): Promise<void> {
selectResourceProfileForSandbox({ isNonInteractive, note, prompt, promptOrDefault }),
stopStaleDashboardListenersForSandbox,
listRegistrySandboxes: registry.listSandboxes,
reconcileRegisteredExtraProviders: (gatewayName) =>
reconcileRegisteredExtraProviders(gatewayName, { runOpenshell }),
createSandbox: preparedDcodeRuntime.bindCreateSandbox(
createSandboxWithBaseImageResolution.bind(null, baseImageResolutionContext),
),
Expand Down
237 changes: 237 additions & 0 deletions src/lib/onboard/extra-provider-diagnostic-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

const DIAGNOSTIC_PREFIXES = ["error:", "rpc error:", "status:"];
const NOT_FOUND_SUFFIXES = new Set([
"not found",
"notfound",
"is not found",
"is notfound",
"was not found",
"was notfound",
]);

// Parser helpers deliberately default to null/false for malformed or unknown
// diagnostics. The caller treats that as `ambiguous-diagnostic`, preserving the
// provider and emitting one redacted aggregate warning for observability.
function stripAnsi(value: string): string {
return value.replace(/\x1B\[[0-?]*[ -/]*[@-~]/gu, "");
}

function stripIssueMarker(text: string): string {
const trimmed = text.trimStart();
return trimmed.startsWith("×") ? trimmed.slice(1).trimStart() : trimmed;
}

function stripIssueDecoration(line: string): string {
const trimmed = stripAnsi(line).trim();
const withoutPipe = trimmed.startsWith("│") ? trimmed.slice(1).trimStart() : trimmed;
return stripIssueMarker(withoutPipe);
}

function joinDiagnosticLines(lines: string[]): string {
return lines
.reduce((message, line) => {
const part = line.trim();
if (!part) return message;
return message.endsWith("-") ? `${message}${part}` : `${message} ${part}`;
}, "")
.trim();
}

function stripDiagnosticPrefixes(line: string): string {
let text = stripIssueDecoration(line);
for (let attempt = 0; attempt < 10; attempt += 1) {
const lower = text.toLowerCase();
const prefix = DIAGNOSTIC_PREFIXES.find((candidate) => lower.startsWith(candidate));
if (!prefix) return text;
text = stripIssueMarker(text.slice(prefix.length));
}
return text;
}

function readQuotedValue(text: string, searchStart = 0): { value: string; end: number } | null {
const quoteIndex = ["'", '"', "`"]
.map((quote) => ({ quote, index: text.indexOf(quote, searchStart) }))
.filter(({ index }) => index >= 0)
.sort((left, right) => left.index - right.index)[0];
if (!quoteIndex) return null;
const end = text.indexOf(quoteIndex.quote, quoteIndex.index + 1);
return end >= 0 ? { value: text.slice(quoteIndex.index + 1, end), end: end + 1 } : null;
}

function lineReportsMissingGateway(line: string): boolean {
const lower = line.replace(/'[^']*'|"[^"]*"|`[^`]*`/gu, "").toLowerCase();
return (
lower.includes("unknown gateway") ||
lower.includes("no such gateway") ||
lower.includes("notfound: gateway") ||
(lower.includes("gateway") &&
(lower.includes("does not exist") ||
lower.includes("not found") ||
lower.includes("notfound")))
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function structuredStatusValue(line: string): string | null {
const lower = line.toLowerCase();
for (const key of ["status", "code"]) {
const keyIndex = lower.indexOf(key);
if (keyIndex < 0) continue;
let cursor = keyIndex + key.length;
while (/\s/u.test(line[cursor] ?? "")) cursor += 1;
if (line[cursor] !== ":" && line[cursor] !== "=") continue;
cursor += 1;
while (/[\s"']/u.test(line[cursor] ?? "")) cursor += 1;
if (line.slice(cursor).toLowerCase().startsWith("some requested entity was not found")) {
return "notfound";
}
const start = cursor;
while (/[a-z_-]/iu.test(line[cursor] ?? "")) cursor += 1;
return line.slice(start, cursor);
}
return null;
}

function normalizeStatus(value: string): string {
return value.replaceAll("_", "").replaceAll("-", "").toLowerCase();
}

function normalizedNotFoundSuffix(value: string): string {
return value
.replace(/[.!]+$/u, "")
.trim()
.toLowerCase();
}

function providerNameFromNotFoundLine(line: string): string | null {
return (
providerNameFromNotFoundText(stripDiagnosticPrefixes(line)) ?? providerNameFromMessage(line)
);
}

function providerNameFromNotFoundText(text: string): string | null {
let hasNotFoundStatusPrefix = false;
if (text.toLowerCase().startsWith("notfound:")) {
text = text.slice("notfound:".length).trimStart();
hasNotFoundStatusPrefix = true;
}
const providerPrefix = "provider ";
if (!text.toLowerCase().startsWith(providerPrefix)) return null;
const quoted = readQuotedValue(text, providerPrefix.length);
if (!quoted) return null;
const suffix = normalizedNotFoundSuffix(text.slice(quoted.end));
return suffix === "" && hasNotFoundStatusPrefix
? quoted.value
: NOT_FOUND_SUFFIXES.has(suffix)
? quoted.value
: null;
}

function providerNameFromMessage(line: string): string | null {
const text = stripDiagnosticPrefixes(line);
const markerIndex = text.toLowerCase().indexOf("message:");
if (markerIndex < 0) return null;
let cursor = markerIndex + "message:".length;
while (/\s/u.test(text[cursor] ?? "")) cursor += 1;
const quote = text[cursor];
if (quote === "'" || quote === '"' || quote === "`") {
const end = text.indexOf(quote, cursor + 1);
if (end < 0) return null;
return providerNameFromNotFoundText(text.slice(cursor + 1, end));
}
return providerNameFromNotFoundText(text.slice(cursor));
}

function readMessageValue(line: string): string | null {
const text = stripDiagnosticPrefixes(line);
const markerIndex = text.toLowerCase().indexOf("message:");
if (markerIndex < 0) return null;
const quoted = readQuotedValue(text, markerIndex + "message:".length);
if (quoted) return quoted.value;
return text.slice(markerIndex + "message:".length).trim();
}

function lineReportsTargetedProviderGetNotFound(line: string): boolean {
const text = stripDiagnosticPrefixes(line);
if (normalizedNotFoundSuffix(text) === "provider not found") return true;
const status = structuredStatusValue(line);
if (!status || normalizeStatus(status) !== "notfound") return false;
return normalizedNotFoundSuffix(readMessageValue(line) ?? "") === "provider not found";
}

function commandNameAfterMarker(text: string, marker: string): string | null {
const markerIndex = text.toLowerCase().indexOf(marker.toLowerCase());
if (markerIndex < 0) return null;
let cursor = markerIndex + marker.length;
while (/\s/u.test(text[cursor] ?? "")) cursor += 1;
const start = cursor;
while (cursor < text.length && !/[\s`]/u.test(text[cursor] ?? "")) cursor += 1;
return cursor > start ? text.slice(start, cursor) : null;
}

function wrappedIssueDiagnosticMatches(
issueDiagnostic: string,
providerName: string,
): boolean | null {
const text = stripDiagnosticPrefixes(issueDiagnostic).replace(/^\s*×\s*/u, "");
const lower = text.toLowerCase();
const providerIndex = lower.indexOf("provider ");
const hasWrappedIssueShape =
providerIndex >= 0 &&
lower.includes(" not found and ") &&
lower.includes(" is not a recognized provider type");
if (!hasWrappedIssueShape) return null;

const firstProvider = readQuotedValue(text, providerIndex);
const secondProvider = firstProvider
? readQuotedValue(text, lower.indexOf(" and ", firstProvider.end))
: null;
const commandProvider = commandNameAfterMarker(text, "--name ");
return (
firstProvider?.value === providerName &&
secondProvider?.value === providerName &&
(commandProvider === null || commandProvider === providerName)
);
}

/**
* Accept only diagnostics that bind "not found" to this exact quoted provider.
*
* OpenShell currently renders both `provider 'name' not found` and the gRPC
* ordering `NotFound: provider "name"`. Keeping these shapes narrow matters:
* gateway failures can mention the provider being queried, but must remain
* indeterminate so onboarding does not silently drop a healthy attachment.
*/
export function reportsExactProviderNotFound(
output: string,
providerName: string,
diagnosticLimit: number,
): boolean {
const lines = output.slice(0, diagnosticLimit).split(/\r?\n/);
const diagnosticLines = lines.map(stripIssueDecoration).filter(Boolean);
if (diagnosticLines.length === 0) return false;
if (diagnosticLines.some(lineReportsMissingGateway)) return false;
if (
diagnosticLines.some((line) => {
const status = structuredStatusValue(line);
return Boolean(status && normalizeStatus(status) !== "notfound");
})
) {
return false;
}

const wrappedIssueMatch = wrappedIssueDiagnosticMatches(
joinDiagnosticLines(diagnosticLines),
providerName,
);
if (wrappedIssueMatch !== null) return wrappedIssueMatch;
if (
diagnosticLines.length === 1 &&
lineReportsTargetedProviderGetNotFound(diagnosticLines[0] ?? "")
) {
return true;
}

return diagnosticLines.every((line) => providerNameFromNotFoundLine(line) === providerName);
}
Loading
Loading