Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
9 changes: 6 additions & 3 deletions src/lib/adapters/config/live-export-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,17 @@ function resolveGatewayBinding(entry: Readonly<SandboxEntry>): { name: string; p

function gatewayFor(entry: Readonly<SandboxEntry>): ObservedExportGateway {
const { name, port } = resolveGatewayBinding(entry);
const configuredStateDir = process.env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR?.trim();
const stateDir = resolveGatewayStateDirForPort({
configured: process.env.NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR,
configured: configuredStateDir,
home: os.homedir(),
port,
});
const stateRootOwned =
managedGatewayStateRootOwnershipFailure({ gatewayName: name, gatewayPort: port, stateDir }) ===
null;
managedGatewayStateRootOwnershipFailure(
{ gatewayName: name, gatewayPort: port, stateDir },
{ allowLegacyManagedState: !configuredStateDir },
) === null;
return {
name,
port,
Expand Down
50 changes: 49 additions & 1 deletion src/lib/adapters/openshell/sandbox-command-sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// 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";
Expand All @@ -20,7 +21,7 @@ const request = (timeoutSeconds = 120) =>
}) as const;

function tlsBundle(): string {
const stateDir = fs.mkdtempSync(path.join(process.cwd(), "nemoclaw-sdk-test-"));
const stateDir = fs.mkdtempSync(path.join(os.homedir(), ".nemoclaw-sdk-test-"));
roots.push(stateDir);
ensureManagedGatewayStateRoot({
gatewayName: "nemoclaw-9443",
Expand All @@ -34,6 +35,17 @@ function tlsBundle(): string {
return stateDir;
}

function unmarkedDefaultTlsBundle(): { homeDir: string; stateDir: string } {
const homeDir = fs.mkdtempSync(path.join(os.homedir(), ".nemoclaw-sdk-default-test-"));
roots.push(homeDir);
const stateDir = path.join(homeDir, ".local", "state", "nemoclaw", "openshell-docker-gateway");
fs.mkdirSync(path.join(stateDir, "tls", "client"), { mode: 0o700, recursive: true });
fs.writeFileSync(path.join(stateDir, "tls", "ca.crt"), "ca");
fs.writeFileSync(path.join(stateDir, "tls", "client", "tls.crt"), "cert");
fs.writeFileSync(path.join(stateDir, "tls", "client", "tls.key"), "key");
return { homeDir, stateDir };
}

afterEach(() => {
for (const root of roots.splice(0)) fs.rmSync(root, { force: true, recursive: true });
});
Expand All @@ -60,6 +72,42 @@ describe("OpenShell SDK sandbox command executor", () => {
});
});

it("connects to the owner-private canonical default state created before markers", async () => {
const { homeDir } = unmarkedDefaultTlsBundle();
const connect = vi.fn().mockResolvedValue({ sandbox: {} });

await connectManagedOpenShellSdk(
{ kind: "named", gatewayName: "nemoclaw" },
{
env: { HOME: homeDir },
homeDir,
loadSdk: async () => ({ OpenShellClient: { connect } }),
},
);

expect(connect).toHaveBeenCalledWith({
gateway: "https://127.0.0.1:8080",
caCert: Buffer.from("ca"),
clientCert: Buffer.from("cert"),
clientKey: Buffer.from("key"),
});
});

it("rejects an unmarked explicit gateway state override", async () => {
const { stateDir } = unmarkedDefaultTlsBundle();

await expect(
connectManagedOpenShellSdk(
{ kind: "named", gatewayName: "nemoclaw" },
{
env: { NEMOCLAW_OPENSHELL_GATEWAY_STATE_DIR: stateDir },
homeDir: "/unused",
loadSdk: async () => ({ OpenShellClient: { connect: vi.fn() } }),
},
),
).rejects.toThrow(/managed gateway state root marker/u);
});

it("streams native output and preserves the exit status", async () => {
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
Expand Down
16 changes: 11 additions & 5 deletions src/lib/adapters/openshell/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,17 @@ export async function connectManagedOpenShellSdk(
port,
});
const gatewayName = target.kind === "named" ? target.gatewayName : "";
const ownershipFailure = managedGatewayStateRootOwnershipFailure({
gatewayName,
gatewayPort: port,
stateDir,
});
const ownershipFailure = managedGatewayStateRootOwnershipFailure(
{
gatewayName,
gatewayPort: port,
stateDir,
},
// The canonical default root predates the explicit marker. Its fixed path,
// owner-only directory checks, and local mTLS identity remain the legacy
// authority boundary. Explicit overrides must always carry the marker.
{ allowLegacyManagedState: !configuredStateDir },
);
if (ownershipFailure) {
const message = `Unsafe OpenShell gateway state directory: ${ownershipFailure}.`;
if (configuredStateDir) throw new Error(message);
Expand Down
11 changes: 11 additions & 0 deletions test/e2e/fixtures/hosted-inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const DEFAULT_HOSTED_INFERENCE_BASE_URL = "https://inference-api.nvidia.c
export const DEFAULT_HOSTED_INFERENCE_MODEL = "nvidia/nvidia/nemotron-3-ultra";

const PORTABLE_DESCRIPTOR_VALIDITY_MS = 60 * 60_000;
const CREDENTIAL_ENV_NAME = /^[A-Z][A-Z0-9_]{0,127}$/u;

export interface HostedInferenceSecrets {
required(name: string): string;
Expand Down Expand Up @@ -50,6 +51,16 @@ export interface HostedInferenceModelsProbe {
env: NodeJS.ProcessEnv;
}

export function hostedInferenceCredentialReferencePattern(credentialEnv: string): RegExp {
if (!CREDENTIAL_ENV_NAME.test(credentialEnv)) {
throw new Error(`invalid hosted inference credential environment name: ${credentialEnv}`);
}
return new RegExp(
`^openshell:resolve:env:(?:(?:v[0-9]{1,20}|s[a-f0-9]{64})_)?${credentialEnv}$`,
"u",
);
}

function currentEffectiveUid(): number {
const uid = process.geteuid?.() ?? process.getuid?.();
if (uid === undefined) {
Expand Down
6 changes: 4 additions & 2 deletions test/e2e/live/inference-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts";
import { resultText } from "../fixtures/clients/command.ts";
import { type E2ETargetFixtures, expect, test } from "../fixtures/e2e-test.ts";
import { startFakeOpenAiCompatibleServer } from "../fixtures/fake-openai-compatible.ts";
import { hostedInferenceCredentialReferencePattern } from "../fixtures/hosted-inference.ts";
import { OPENSHELL_V0116_QUALIFICATION } from "../fixtures/openshell-v0116-qualification.ts";
import { REPO_ROOT } from "../fixtures/paths.ts";
import { resolveVerifiedCloudflaredBinary } from "./cloudflared-prerequisite.ts";
Expand Down Expand Up @@ -744,6 +745,7 @@ async function runRuntimeIdentityE2EScenario(
progress.phase("call the protected resource with the injected bearer");
let placeholder = "";
let placeholderProbeAttempt = 0;
const placeholderPattern = hostedInferenceCredentialReferencePattern(credentialKey);
await expect
.poll(
async () => {
Expand All @@ -758,8 +760,8 @@ async function runRuntimeIdentityE2EScenario(
},
{ interval: 2_000, timeout: 35_000 },
)
.toMatch(new RegExp(`^openshell:resolve:env:(?:v[0-9]+_)?${credentialKey}$`));
expect(placeholder).toMatch(new RegExp(`^openshell:resolve:env:(?:v[0-9]+_)?${credentialKey}$`));
.toMatch(placeholderPattern);
expect(placeholder).toMatch(placeholderPattern);
for (const secret of redactionValues) expect(placeholder).not.toContain(secret);
const expectProtectedResourceVersion = async (
projectedPlaceholder: string,
Expand Down
15 changes: 15 additions & 0 deletions test/e2e/support/hosted-inference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ProviderClient, trustedProviderEndpoint } from "../fixtures/clients/pro
import { startFakeOpenAiCompatibleServer } from "../fixtures/fake-openai-compatible.ts";
import {
buildHostedInferenceModelsProbe,
hostedInferenceCredentialReferencePattern,
requireHostedInferenceConfig,
stagePortableHostedInferenceDescriptor,
} from "../fixtures/hosted-inference.ts";
Expand Down Expand Up @@ -187,6 +188,20 @@ printf 'modelFn=%s\n' "$(nemoclaw_e2e_hosted_inference_model)"
}

describe("hosted inference E2E config", () => {
it("accepts canonical OpenShell credential references", () => {
const pattern = hostedInferenceCredentialReferencePattern("E2E_ACCESS_TOKEN");
const stableRevision = `s${"a".repeat(64)}`;

expect("openshell:resolve:env:E2E_ACCESS_TOKEN").toMatch(pattern);
expect("openshell:resolve:env:v42_E2E_ACCESS_TOKEN").toMatch(pattern);
expect(`openshell:resolve:env:${stableRevision}_E2E_ACCESS_TOKEN`).toMatch(pattern);
expect("openshell:resolve:env:v123456789012345678901_E2E_ACCESS_TOKEN").not.toMatch(pattern);
expect(`openshell:resolve:env:s${"a".repeat(63)}_E2E_ACCESS_TOKEN`).not.toMatch(pattern);
expect(() => hostedInferenceCredentialReferencePattern("E2E-ACCESS-TOKEN")).toThrow(
/invalid hosted inference credential environment name/u,
);
});

it("uses the public NVIDIA route for preinstalled Launchable onboarding", () => {
const cfg = requireHostedInferenceConfig(
secrets({ NVIDIA_INFERENCE_API_KEY: "nvapi-launchable-test" }),
Expand Down
Loading