Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
7563c9a
fix(openshell): reconcile existing provider profiles
rsliter Aug 24, 2026
900ea1d
Merge branch 'main' into codex/fix-provider-profile-reconciliation-clean
rsliter Aug 25, 2026
99461e2
Merge branch 'main' into codex/fix-provider-profile-reconciliation-clean
rsliter Aug 25, 2026
5943755
fix(providers): complete profile reconciliation
rsliter Aug 25, 2026
b7d59ca
test(onboarding): align provider profile fixtures
apurvvkumaria Aug 25, 2026
03e09a7
fix(credentials): validate OpenAI profiles
apurvvkumaria Aug 25, 2026
633a0d6
fix(credentials): bound profile reconciliation commands
rsliter Aug 25, 2026
bfa0cd4
merge: refresh main for provider profile fix
rsliter Aug 25, 2026
d6db6dd
merge: refresh main for provider profile fix
rsliter Aug 25, 2026
70c77a8
fix(providers): guard remaining profile mutations
rsliter Aug 25, 2026
c68baad
refactor(openshell): share provider profile reconciliation
rsliter Aug 25, 2026
9261dea
fix(openshell): bound provider profile commands
apurvvkumaria Aug 25, 2026
9bcd54b
merge: refresh main for provider profile fix
apurvvkumaria Aug 25, 2026
00c33f8
test(openshell): expect bounded profile commands
apurvvkumaria Aug 25, 2026
df4a02c
merge: refresh main for provider profile reconciliation
apurvvkumaria Aug 25, 2026
75c008c
fix(providers): guard Portable OpenAI profile
rsliter Aug 25, 2026
e6beea2
fix(openshell): reuse inference profile guard
rsliter Aug 25, 2026
f519045
fix(openshell): preserve profile reconciliation guards
prekshivyas Aug 25, 2026
a03a74f
merge: preserve provider profile guard updates
rsliter Aug 25, 2026
c624358
fix(inference): revalidate resumed Portable profile
rsliter Aug 25, 2026
97f1482
fix(inference): guard remaining OpenAI provider mutations
rsliter Aug 25, 2026
4afca06
merge(main): refresh #10159 candidate
rsliter Aug 25, 2026
532b949
docs(inference): update token rotation lifecycle
rsliter Aug 25, 2026
0fc00cd
refactor(messaging): narrow provider profile exports
cv Aug 25, 2026
bdc5ca0
merge(main): refresh #10159 candidate
rsliter Aug 25, 2026
fccc78a
merge(provider-profile): preserve external export test
rsliter Aug 25, 2026
88dd91f
merge(main): refresh #10159 candidate
rsliter Aug 25, 2026
453d474
merge(main): refresh #10159 candidate
rsliter Aug 25, 2026
a78e69b
test(security): remove unused profile fixture
cv Aug 25, 2026
79808c5
merge(main): refresh #10159 candidate
rsliter Aug 25, 2026
8862376
merge(main): refresh #10159 candidate
rsliter Aug 25, 2026
c7bde32
merge(main): refresh #10159 candidate
rsliter Aug 25, 2026
5d2c4e4
fix(providers): centralize OpenAI profile checks
rsliter Aug 25, 2026
9dcfc31
merge(main): refresh provider profile reconciliation
rsliter Aug 25, 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
152 changes: 152 additions & 0 deletions src/commands/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ vi.mock("../lib/actions/global", () => ({
listManagedMcpCredentialReservations: mocks.listManagedMcpCredentialReservations,
}));
vi.mock("../lib/adapters/openshell/provider-command", () => ({
OPENSHELL_OPERATION_TIMEOUT_MS: 30_000,
runOpenshellProviderCommand: mocks.runOpenshellProviderCommand,
}));
vi.mock("../lib/onboard/gateway-teardown-authority", () => ({
Expand Down Expand Up @@ -219,4 +220,155 @@ describe("credentials oclif adapter source coverage", () => {
mocks.runOpenshellProviderCommand.mock.invocationCallOrder[0],
);
});

it("rejects an incompatible OpenAI profile before provider creation", async () => {
vi.stubEnv("OPENAI_API_KEY", "host-only-secret");
mocks.runOpenshellProviderCommand.mockReturnValueOnce({
status: 0,
stdout: JSON.stringify({
id: "openai",
credentials: [],
endpoints: [{ name: "untrusted", url: "https://example.invalid" }],
binaries: [],
inference_capable: true,
}),
stderr: "",
});

const result = await runCredentialsAddAction({
provider: "openai-prod",
type: "openai",
credentials: ["OPENAI_API_KEY"],
configPairs: [],
fromExisting: false,
});

expect(result.exitCode).toBe(1);
expect(result.failureLines.join("\n")).toContain(
"does not match NemoClaw's endpointless inference contract",
);
expect(result.failureLines.join("\n")).toContain("then retry this command");
expect(result.failureLines.join("\n")).not.toContain("onboarding");
expect(result.failureLines.join("\n")).not.toContain("host-only-secret");
expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledTimes(1);
expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledWith(
["provider", "profile", "export", "openai", "--output", "json"],
{
ignoreError: true,
suppressOutput: true,
stdio: ["ignore", "pipe", "pipe"],
timeout: 30_000,
},
);
expect(mocks.recordExtraProvider).not.toHaveBeenCalled();
});

it("stops before provider creation when OpenAI profile inspection times out", async () => {
vi.stubEnv("OPENAI_API_KEY", "host-only-secret");
mocks.runOpenshellProviderCommand.mockReturnValueOnce({
status: null,
stdout: "",
stderr: "operation timed out",
});

const result = await runCredentialsAddAction({
provider: "openai-prod",
type: "openai",
credentials: ["OPENAI_API_KEY"],
configPairs: [],
fromExisting: false,
});

expect(result.exitCode).toBe(1);
expect(result.failureLines.join("\n")).toContain("could not be read for validation");
expect(result.failureLines.join("\n")).toContain("then retry this command");
expect(result.failureLines.join("\n")).not.toContain("onboarding");
expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledOnce();
expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledWith(
["provider", "profile", "export", "openai", "--output", "json"],
{
ignoreError: true,
suppressOutput: true,
stdio: ["ignore", "pipe", "pipe"],
timeout: 30_000,
},
);
expect(mocks.recordExtraProvider).not.toHaveBeenCalled();
});

it("imports a missing OpenAI profile before provider creation", async () => {
vi.stubEnv("OPENAI_API_KEY", "host-only-secret");
mocks.runOpenshellProviderCommand
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "provider profile not found" })
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" })
.mockReturnValueOnce({ status: 0, stdout: "", stderr: "" });

const result = await runCredentialsAddAction({
provider: "openai-prod",
type: "openai",
credentials: ["OPENAI_API_KEY"],
configPairs: [],
fromExisting: false,
});

expect(result.exitCode).toBe(0);
expect(mocks.runOpenshellProviderCommand.mock.calls.map(([args]) => args)).toEqual([
["provider", "profile", "export", "openai", "--output", "json"],
[
"provider",
"profile",
"import",
"--file",
expect.stringMatching(/provider-profiles\/openai\.yaml$/u),
],
[
"provider",
"create",
"--name",
"openai-prod",
"--type",
"openai",
"--credential",
"OPENAI_API_KEY",
],
]);
expect(
mocks.runOpenshellProviderCommand.mock.calls.slice(0, 2).map(([, options]) => options),
).toEqual([
{
ignoreError: true,
suppressOutput: true,
stdio: ["ignore", "pipe", "pipe"],
timeout: 30_000,
},
{
ignoreError: true,
suppressOutput: true,
stdio: ["ignore", "pipe", "pipe"],
timeout: 30_000,
},
]);
});

it("reports caller-neutral guidance when OpenAI profile import fails", async () => {
vi.stubEnv("OPENAI_API_KEY", "host-only-secret");
mocks.runOpenshellProviderCommand
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "provider profile not found" })
.mockReturnValueOnce({ status: 1, stdout: "", stderr: "import failed" });

const result = await runCredentialsAddAction({
provider: "openai-prod",
type: "openai",
credentials: ["OPENAI_API_KEY"],
configPairs: [],
fromExisting: false,
});

expect(result.exitCode).toBe(1);
expect(result.failureLines.join("\n")).toContain("could not import the checked-in");
expect(result.failureLines.join("\n")).toContain("then retry this command");
expect(result.failureLines.join("\n")).not.toContain("onboarding");
expect(mocks.runOpenshellProviderCommand).toHaveBeenCalledTimes(2);
expect(mocks.recordExtraProvider).not.toHaveBeenCalled();
});
});
20 changes: 19 additions & 1 deletion src/lib/actions/credentials-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
import fs from "node:fs";
import path from "node:path";
import { runOpenshellProviderCommand } from "../adapters/openshell/provider-command";
import {
checkOpenAiInferenceProviderProfile,
OPENAI_GATEWAY_PROVIDER_TYPE,
} from "../adapters/openshell/provider-profile";
import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../adapters/openshell/timeouts";
import { CLI_NAME } from "../cli/branding";
import {
Expand Down Expand Up @@ -142,6 +146,20 @@ function ensureBundledProviderProfile(type: string): CredentialsAddResult | null
]);
}

function ensureCredentialProviderProfile(type: string): CredentialsAddResult | null {
if (type.toLowerCase() !== OPENAI_GATEWAY_PROVIDER_TYPE) {
return ensureBundledProviderProfile(type);
}
const profile = checkOpenAiInferenceProviderProfile({
runOpenshell: (args, options) =>
runOpenshellProviderCommand(args, {
...options,
timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
}),
});
return profile.ok ? null : fail(profile.messages);
}

export async function runCredentialsAddAction(
input: CredentialsAddInput,
): Promise<CredentialsAddResult> {
Expand Down Expand Up @@ -251,7 +269,7 @@ export async function runCredentialsAddAction(
return fail(recoveryFailureLines);
}

const providerProfileFailure = ensureBundledProviderProfile(type);
const providerProfileFailure = ensureCredentialProviderProfile(type);
if (providerProfileFailure) return providerProfileFailure;

let importedCredentialKeys: string[] | null = null;
Expand Down
20 changes: 20 additions & 0 deletions src/lib/actions/inference-set-compatible-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ type ProbeSandboxRoute = NonNullable<
Parameters<typeof createDeps>[0]["probeSandboxRoute"]
>;

const OPENAI_PROFILE_OUTPUT = JSON.stringify({
id: "openai",
credentials: [],
endpoints: [],
binaries: [],
inference_capable: true,
});
const OPENAI_PROFILE_RESULT = {
status: 0,
output: OPENAI_PROFILE_OUTPUT,
stdout: OPENAI_PROFILE_OUTPUT,
stderr: "",
};

async function runRejectedCompatibleSwitchScenario(options: {
targetFamily: "openai" | "anthropic";
probeSandboxRoute: ProbeSandboxRoute;
Expand Down Expand Up @@ -295,6 +309,8 @@ describe("runInferenceSet compatible providers", () => {
let providerCreated = false;
const captureOpenshell = vi.fn((args: string[]) => {
switch (`${args[0]}:${args[1]}`) {
case "provider:profile":
return OPENAI_PROFILE_RESULT;
case "inference:set":
return providerCreated
? { status: 0, output: "", stdout: "", stderr: "" }
Expand Down Expand Up @@ -407,6 +423,8 @@ describe("runInferenceSet compatible providers", () => {
let providerPresent = false;
const captureOpenshell = vi.fn((args: string[]) => {
switch (`${args[0]}:${args[1]}`) {
case "provider:profile":
return OPENAI_PROFILE_RESULT;
case "provider:get": {
const missingOutput =
"Error: code: 'Some requested entity was not found', message: \"provider not found\"";
Expand Down Expand Up @@ -599,6 +617,8 @@ describe("runInferenceSet compatible providers", () => {
let providerVersion = 1;
const captureOpenshell = vi.fn((args: string[]) => {
switch (`${args[0]}:${args[1]}`) {
case "provider:profile":
return OPENAI_PROFILE_RESULT;
case "provider:get": {
const output = [
"Name: compatible-endpoint",
Expand Down
24 changes: 24 additions & 0 deletions src/lib/actions/inference-set-https-pin-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ const OLD_ROUTE_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
const ADAPTER_BASE_URL = `http://host.openshell.internal:11438/route/${NEW_ROUTE_ID}`;
const OLD_ADAPTER_BASE_URL = `http://host.openshell.internal:11438/route/${OLD_ROUTE_ID}`;
const PROVIDER_ID = "11111111-2222-4333-8444-555555555555";
const OPENAI_PROFILE_OUTPUT = JSON.stringify({
id: "openai",
credentials: [],
endpoints: [],
binaries: [],
inference_capable: true,
});
const ANTHROPIC_PROFILE_OUTPUT = JSON.stringify({
id: "anthropic",
credentials: [],
endpoints: [],
binaries: [],
inference_capable: true,
});

function mockAdapter() {
return vi.fn(async (_options: EnsureHttpsPinRuntimeAdapterOptions) => ({
Expand Down Expand Up @@ -43,6 +57,16 @@ function providerCapture(options: {
].join("\n");
return vi.fn((args: string[]) => {
switch (`${args[0]}:${args[1]}`) {
case "provider:profile": {
const profile =
options.providerType === "anthropic" ? ANTHROPIC_PROFILE_OUTPUT : OPENAI_PROFILE_OUTPUT;
return {
status: 0,
output: profile,
stdout: profile,
stderr: "",
};
}
case "provider:get": {
const text = output();
return { status: 0, stdout: text, stderr: "", output: text };
Expand Down
54 changes: 40 additions & 14 deletions src/lib/actions/inference-set-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,27 @@
// SPDX-License-Identifier: Apache-2.0

import { afterEach, describe, expect, it, vi } from "vitest";
import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../adapters/openshell/provider-command";
import type { InferenceSetDeps } from "./inference-set";
import { __test, prepareInferenceSetProviderBinding } from "./inference-set-provider";
import type { HttpsPinProviderBinding } from "./inference-set-route-containment";

const PROVIDER_ID = "11111111-2222-4333-8444-555555555555";
const OPENAI_ENDPOINTLESS_PROFILE = JSON.stringify({
id: "openai",
credentials: [],
endpoints: [],
binaries: [],
inference_capable: true,
});

const OPENAI_ENDPOINTLESS_PROFILE_RESULT = {
status: 0,
stdout: OPENAI_ENDPOINTLESS_PROFILE,
stderr: "",
output: OPENAI_ENDPOINTLESS_PROFILE,
};

function binding(overrides: Partial<HttpsPinProviderBinding> = {}): HttpsPinProviderBinding {
return {
baseUrl: "http://host.openshell.internal:11438/route/route-a/v1",
Expand Down Expand Up @@ -42,7 +58,7 @@ function captureSequence(
return vi.fn(
(args: string[]) =>
(args[0] === "provider" && args[1] === "profile"
? { status: 0, stdout: "", stderr: "" }
? OPENAI_ENDPOINTLESS_PROFILE_RESULT
: results.shift()) ??
(() => {
throw new Error("unexpected OpenShell call");
Expand Down Expand Up @@ -76,10 +92,17 @@ describe("inference set provider binding", () => {
"profile",
"-g",
"nemoclaw",
"import",
"--file",
expect.stringMatching(/openai\.yaml$/u),
"export",
"openai",
"--output",
"json",
]);
expect(capture.mock.calls[1][1]).toEqual({
ignoreError: true,
includeStreams: true,
maxBuffer: 64 * 1024,
timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
});
expect(capture.mock.calls[2]).toEqual([
[
"provider",
Expand Down Expand Up @@ -121,14 +144,17 @@ describe("inference set provider binding", () => {

it("stops before an OpenAI provider mutation when profile registration fails (#9895)", () => {
const before = providerOutput({ resourceVersion: 4 });
const responses = new Map([
["get", { status: 0, stdout: before, stderr: "", output: before }],
["profile", { status: 1, stdout: "", stderr: "sensitive profile failure" }],
]);
const capture = vi.fn((args: string[]) =>
responses.get(args[1]) ?? (() => {
throw new Error("provider mutation must not run");
})(),
const responses = [
{ status: 0, stdout: before, stderr: "", output: before },
{ status: 1, stdout: "", stderr: "provider profile not found" },
{ status: 1, stdout: "", stderr: "sensitive profile failure" },
];
const capture = vi.fn(
() =>
responses.shift() ??
(() => {
throw new Error("provider mutation must not run");
})(),
) as InferenceSetDeps["captureOpenshell"] & ReturnType<typeof vi.fn>;

const mutation = prepareInferenceSetProviderBinding({
Expand All @@ -141,7 +167,7 @@ describe("inference set provider binding", () => {
expect(() => mutation.commit()).toThrow(
"could not import the checked-in 'openai' inference provider profile",
);
expect(capture.mock.calls.map(([args]) => args[1])).toEqual(["get", "profile"]);
expect(capture.mock.calls.map(([args]) => args[1])).toEqual(["get", "profile", "profile"]);
});

it("does not register the OpenAI profile before an Anthropic provider mutation", () => {
Expand Down Expand Up @@ -282,7 +308,7 @@ describe("inference set provider binding", () => {
return (args, opts) => {
switch (args[1]) {
case "profile":
return { status: 0, stdout: "", stderr: "", output: "" };
return OPENAI_ENDPOINTLESS_PROFILE_RESULT;
case "get": {
const output = providerOutput({ id, resourceVersion: version });
return { status: 0, stdout: output, stderr: "", output };
Expand Down
Loading
Loading