Skip to content
Closed
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
7645547
refactor(cli): add typed OpenShell sandbox observer
rsliter Aug 24, 2026
233c318
fix(cli): preserve OpenShell observation parity
rsliter Aug 24, 2026
a0ef184
Merge branch 'main' into codex/openshell-adapter-inventory
cv Aug 24, 2026
cd705b1
refactor(cli): tighten OpenShell observation boundary
rsliter Aug 24, 2026
eb9f690
docs(cli): clarify gateway identity drift
rsliter Aug 24, 2026
7418fa2
refactor(cli): add typed OpenShell provider adapter
rsliter Aug 24, 2026
101246c
test(cli): exercise doctor observation from source
rsliter Aug 24, 2026
159095c
merge(cli): refresh OpenShell adapter dependency
rsliter Aug 24, 2026
0e412df
fix(cli): fail closed on sandbox observation errors
rsliter Aug 24, 2026
bbe5830
merge(cli): refresh OpenShell adapter dependency
rsliter Aug 24, 2026
5dbab5d
test(cli): align rebuild recovery expectations
rsliter Aug 24, 2026
9bbf111
merge(cli): refresh OpenShell adapter dependency
rsliter Aug 24, 2026
5a091ce
merge(cli): refresh provider adapter onto main
rsliter Aug 27, 2026
0e7ea66
fix(cli): address provider adapter review findings
rsliter Aug 27, 2026
87d3c75
fix(cli): close provider adapter trust gaps
rsliter Aug 27, 2026
b234e73
merge(cli): refresh provider adapter onto main
rsliter Aug 27, 2026
a1743bc
test(cli): remove stale provider adapter import
rsliter Aug 27, 2026
4dfbd0d
Merge remote-tracking branch 'origin/main' into codex/openshell-provi…
rsliter Aug 28, 2026
ce5afa4
Merge remote-tracking branch 'origin/main' into codex/openshell-provi…
rsliter Aug 28, 2026
10b4999
fix(cli): address provider adapter review findings
rsliter Aug 28, 2026
abda0e7
test(cli): preserve provider command partial mock
rsliter Aug 28, 2026
140a2c6
test(cli): cover provider detach parity
rsliter Aug 28, 2026
1de41cb
fix(cli): close provider recovery review gaps
rsliter Aug 28, 2026
dfebbd7
test(cli): cover empty provider inventory
rsliter Aug 28, 2026
187c592
merge: resolve conflicts with main
github-actions[bot] Aug 28, 2026
ce0009c
merge: resolve conflicts with main
github-actions[bot] Aug 29, 2026
581e0f1
merge: resolve conflicts with main
github-actions[bot] Aug 30, 2026
ff3b4a2
chore(cli): lower source architecture ratchets
rsliter Aug 31, 2026
78cbe7f
fix(cli): validate reset provider names
rsliter Aug 31, 2026
c01f776
fix(cli): settle concurrent provider deletion
rsliter Aug 31, 2026
000f742
refactor(onboard): route provider publication through adapter
rsliter Aug 31, 2026
910c4ea
test(onboard): cover provider update failure
rsliter Aug 31, 2026
dde0937
merge: refresh provider publication adapter
rsliter Sep 2, 2026
e9af711
fix(onboard): guard messaging profile gateway
rsliter Sep 2, 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
5 changes: 3 additions & 2 deletions ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"src/lib/adapters/openshell/client.ts": 20,
"src/lib/adapters/openshell/resolve.ts": 27,
"src/lib/adapters/openshell/runtime.ts": 55,
"src/lib/adapters/openshell/timeouts.ts": 38,
"src/lib/adapters/openshell/timeouts.ts": 39,
"src/lib/agent/defs.ts": 33,
"src/lib/cli/branding.ts": 85,
"src/lib/cli/nemoclaw-oclif-command.ts": 106,
Expand All @@ -22,9 +22,10 @@
"src/lib/credentials/store.ts": 45,
"src/lib/inference/config.ts": 30,
"src/lib/messaging/channels/index.ts": 25,
"src/lib/name-validation.ts": 19,
"src/lib/onboard/gateway-binding.ts": 53,
"src/lib/runner.ts": 85,
"src/lib/security/redact.ts": 54,
"src/lib/security/redact.ts": 53,
"src/lib/state/mcp-lifecycle-lock.ts": 21,
"src/lib/state/onboard-session.ts": 35,
"src/lib/state/registry.ts": 97,
Expand Down
2 changes: 1 addition & 1 deletion docs/get-started/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ Use these details when your first-run path needs more control.
Logs: nemoclaw my-gpt-claw logs --follow
Model: nemoclaw inference set --model <model> --provider <provider> --sandbox my-gpt-claw
Policies: nemoclaw my-gpt-claw policy add
Credentials: nemoclaw credentials reset <KEY> && nemoclaw onboard
Credentials: nemoclaw credentials reset <PROVIDER> && nemoclaw onboard
──────────────────────────────────────────────────
```

Expand Down
12 changes: 8 additions & 4 deletions src/commands/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@ vi.mock("../lib/actions/global", () => ({
forgetExtraProvider: mocks.forgetExtraProvider,
listManagedMcpCredentialReservations: mocks.listManagedMcpCredentialReservations,
}));
vi.mock("../lib/adapters/openshell/provider-command", () => ({
OPENSHELL_OPERATION_TIMEOUT_MS: 30_000,
runOpenshellProviderCommand: mocks.runOpenshellProviderCommand,
}));
vi.mock("../lib/adapters/openshell/provider-command", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/adapters/openshell/provider-command")>();
return {
...actual,
OPENSHELL_OPERATION_TIMEOUT_MS: 30_000,
runOpenshellProviderCommand: mocks.runOpenshellProviderCommand,
};
});
vi.mock("../lib/onboard/gateway-teardown-authority", () => ({
resolveGatewayCredentialMutationAuthority: mocks.resolveGatewayCredentialMutationAuthority,
}));
Expand Down
170 changes: 69 additions & 101 deletions src/lib/actions/credentials-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@

import fs from "node:fs";
import path from "node:path";
import { runOpenshellProviderCommand } from "../adapters/openshell/provider-command";
import { createCliOpenShellProviderAdapter } from "../adapters/openshell/provider-adapter-cli";
import type { OpenShellProviderAdapter } from "../adapters/openshell/provider-adapter";
import {
checkOpenAiInferenceProviderProfile,
endpointlessProviderProfileFailureMessages,
OPENAI_GATEWAY_PROVIDER_TYPE,
} from "../adapters/openshell/provider-profile";
import { selectedOpenShellGateway } from "../adapters/openshell/sandbox-observer";
import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../adapters/openshell/timeouts";
import { CLI_NAME } from "../cli/branding";
import {
isBridgeProviderName,
recoverGatewayForCredentialMutationOrExit,
} from "../credentials/command-support";
import { redact } from "../security/redact";
import { SECRET_PATTERNS } from "../security/secret-patterns";
import { withMcpCredentialOwnershipLock } from "../state/mcp-lifecycle-lock/credential-ownership";
import { ROOT } from "../state/paths";
Expand All @@ -38,6 +39,10 @@ export type CredentialsAddResult = {
failureLines: readonly string[];
};

export type CredentialsAddDeps = Readonly<{
providerAdapter?: OpenShellProviderAdapter;
}>;

const ENV_NAME_PATTERN = /^[A-Z][A-Z0-9_]{0,255}$/;
const CONFIG_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
const CONFIG_KEY_DENYLIST =
Expand Down Expand Up @@ -74,96 +79,60 @@ function managedMcpCollisionFailure(
return null;
}

function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function parseProviderProfileCredentialKeys(output: string): string[] | null {
let profile: unknown;
try {
profile = JSON.parse(output);
} catch {
return null;
}
if (!isObjectRecord(profile) || !Array.isArray(profile.credentials)) return null;

const keys = new Set<string>();
for (const credential of profile.credentials) {
if (!isObjectRecord(credential) || !Array.isArray(credential.env_vars)) return null;
for (const key of credential.env_vars) {
if (typeof key !== "string" || !ENV_NAME_PATTERN.test(key)) return null;
keys.add(key);
}
}
return [...keys].sort();
}

function inspectProviderProfileCredentialKeys(type: string): {
credentialKeys: string[] | null;
diagnostic: string;
} {
const result = runOpenshellProviderCommand(
["provider", "profile", "export", type, "--output", "json"],
{
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
},
);
return {
credentialKeys:
result.status === 0 ? parseProviderProfileCredentialKeys(String(result.stdout || "")) : null,
diagnostic: redact(`${String(result.stderr || "")} ${String(result.stdout || "")}`).trim(),
};
}

function bundledProviderProfilePath(type: string): string {
return path.join(ROOT, "nemoclaw-blueprint", "provider-profiles", `${type.toLowerCase()}.yaml`);
}

function ensureBundledProviderProfile(type: string): CredentialsAddResult | null {
async function ensureBundledProviderProfile(
type: string,
providerAdapter: OpenShellProviderAdapter,
): Promise<CredentialsAddResult | null> {
const profilePath = bundledProviderProfilePath(type);
if (!fs.existsSync(profilePath)) return null;

const result = runOpenshellProviderCommand(
["provider", "profile", "import", "--file", profilePath],
{
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
},
);
if (result.status === 0) return null;

const rawDiagnostic = `${String(result.stderr || "")} ${String(result.stdout || "")}`;
if (/already exists/i.test(rawDiagnostic)) return null;

const redactedDiagnostic = redact(rawDiagnostic).trim();
const result = await providerAdapter.importProviderProfile({
target: selectedOpenShellGateway(),
profilePath,
timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS,
});
if (result.ok) return null;
return fail([
` Could not import bundled provider profile '${type}'.`,
" Update OpenShell with scripts/install-openshell.sh and retry.",
...(redactedDiagnostic ? [` ${redactedDiagnostic}`] : []),
...(result.error.message ? [` ${result.error.message}`] : []),
]);
}

function ensureCredentialProviderProfile(type: string): CredentialsAddResult | null {
async function ensureCredentialProviderProfile(
type: string,
providerAdapter: OpenShellProviderAdapter,
): Promise<CredentialsAddResult | null> {
if (type.toLowerCase() !== OPENAI_GATEWAY_PROVIDER_TYPE) {
return ensureBundledProviderProfile(type);
return ensureBundledProviderProfile(type, providerAdapter);
}
const profile = checkOpenAiInferenceProviderProfile({
runOpenshell: (args, options) =>
runOpenshellProviderCommand(args, {
...options,
timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
}),
const profile = await providerAdapter.ensureEndpointlessProviderProfile({
target: selectedOpenShellGateway(),
profileType: OPENAI_GATEWAY_PROVIDER_TYPE,
profilePath: bundledProviderProfilePath(OPENAI_GATEWAY_PROVIDER_TYPE),
inferenceCapable: true,
timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS,
});
return profile.ok ? null : fail(profile.messages);
if (profile.ok) return null;
const reason =
profile.error.kind === "command" && profile.error.reason === "profile_import_failed"
? "import-failed"
: profile.error.kind === "command" && profile.error.reason === "profile_incompatible"
? "incompatible"
: "export-failed";
return fail(endpointlessProviderProfileFailureMessages(reason));
}

export async function runCredentialsAddAction(
input: CredentialsAddInput,
deps: CredentialsAddDeps = {},
): Promise<CredentialsAddResult> {
const { provider, type, credentials, configPairs, fromExisting } = input;
const providerAdapter = deps.providerAdapter ?? createCliOpenShellProviderAdapter();

if (!PROVIDER_NAME_PATTERN.test(provider)) {
return fail([
Expand Down Expand Up @@ -269,35 +238,32 @@ export async function runCredentialsAddAction(
return fail(recoveryFailureLines);
}

const providerProfileFailure = ensureCredentialProviderProfile(type);
const providerProfileFailure = await ensureCredentialProviderProfile(type, providerAdapter);
if (providerProfileFailure) return providerProfileFailure;

let importedCredentialKeys: string[] | null = null;
if (fromExisting) {
const inspection = inspectProviderProfileCredentialKeys(type);
if (!inspection.credentialKeys) {
const inspection = await providerAdapter.inspectProviderProfile({
target: selectedOpenShellGateway(),
profileType: type,
timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS,
});
if (!inspection.ok) {
return fail([
` Could not inspect credential keys for provider profile '${type}'.`,
" Refusing --from-existing because the provider profile credential keys could not be compared with managed MCP reservations.",
...(inspection.diagnostic ? [` ${inspection.diagnostic}`] : []),
...(inspection.error.message ? [` ${inspection.error.message}`] : []),
]);
}
importedCredentialKeys = inspection.credentialKeys;
importedCredentialKeys = [...inspection.value.credentialKeys];
}

const openshellArgs: string[] = ["provider", "create", "--name", provider, "--type", type];
if (fromExisting) {
openshellArgs.push("--from-existing");
} else {
for (const credential of credentials) {
openshellArgs.push("--credential", credential);
}
}
for (const configPair of configPairs) {
openshellArgs.push("--config", configPair);
}
const config = configPairs.map((configPair) => {
const separator = configPair.indexOf("=");
return { key: configPair.slice(0, separator), value: configPair.slice(separator + 1) };
});

return withMcpCredentialOwnershipLock(() => {
return withMcpCredentialOwnershipLock(async () => {
const providerCredentialKeys = importedCredentialKeys ?? credentials;
const collision = managedMcpCollisionFailure(
provider,
Expand All @@ -309,16 +275,20 @@ export async function runCredentialsAddAction(
const recordedReservation = recordExtraProvider(provider);
let keepReservation = false;
try {
const result = runOpenshellProviderCommand(openshellArgs, {
env: Object.fromEntries(
credentials.map((credential) => [credential, process.env[credential]]),
),
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
timeout: OPENSHELL_OPERATION_TIMEOUT_MS,
const result = await providerAdapter.createProvider({
target: selectedOpenShellGateway(),
name: provider,
type,
credentials: credentials.map((credential) => ({
name: credential,
value: process.env[credential] ?? "",
})),
config,
fromExisting,
timeoutMs: OPENSHELL_OPERATION_TIMEOUT_MS,
});

if (result.status === 0) {
if (result.ok) {
keepReservation = true;
return ok([
` Registered provider '${provider}' with the OpenShell gateway.`,
Expand All @@ -327,17 +297,15 @@ export async function runCredentialsAddAction(
]);
}

const rawStderr = String(result.stderr || "").trim();
const redactedStderr = redact(rawStderr);
const lines = [` Could not register provider '${provider}'.`];
if (/already exists/i.test(rawStderr)) {
if (result.error.kind === "command" && result.error.reason === "already_exists") {
lines.push(
"",
` '${provider}' is already registered.`,
` Run '${CLI_NAME} credentials reset ${provider} --yes' first if you need to replace it.`,
);
} else if (redactedStderr) {
lines.push(` ${redactedStderr}`);
} else if (result.error.message) {
lines.push(` ${result.error.message}`);
}
return fail(lines);
} finally {
Expand Down
Loading
Loading