diff --git a/src/lib/adapters/config/live-export-source.ts b/src/lib/adapters/config/live-export-source.ts index e3aad21d2ac..98555e76945 100644 --- a/src/lib/adapters/config/live-export-source.ts +++ b/src/lib/adapters/config/live-export-source.ts @@ -64,14 +64,17 @@ function resolveGatewayBinding(entry: Readonly): { name: string; p function gatewayFor(entry: Readonly): 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, diff --git a/src/lib/adapters/openshell/sandbox-command-sdk.test.ts b/src/lib/adapters/openshell/sandbox-command-sdk.test.ts index 0dbf82d3105..c985699d55e 100644 --- a/src/lib/adapters/openshell/sandbox-command-sdk.test.ts +++ b/src/lib/adapters/openshell/sandbox-command-sdk.test.ts @@ -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"; @@ -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", @@ -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 }); }); @@ -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[] = []; diff --git a/src/lib/adapters/openshell/sdk.ts b/src/lib/adapters/openshell/sdk.ts index 6eeaddc5723..4e4fabfe4c9 100644 --- a/src/lib/adapters/openshell/sdk.ts +++ b/src/lib/adapters/openshell/sdk.ts @@ -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); diff --git a/test/e2e/fixtures/hosted-inference.ts b/test/e2e/fixtures/hosted-inference.ts index 253cb4cacd7..eb0c9ea4376 100644 --- a/test/e2e/fixtures/hosted-inference.ts +++ b/test/e2e/fixtures/hosted-inference.ts @@ -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; @@ -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) { diff --git a/test/e2e/live/inference-routing.test.ts b/test/e2e/live/inference-routing.test.ts index 13128a30e82..8c6615aaa94 100644 --- a/test/e2e/live/inference-routing.test.ts +++ b/test/e2e/live/inference-routing.test.ts @@ -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"; @@ -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 () => { @@ -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, @@ -875,7 +877,7 @@ async function runRuntimeIdentityE2EScenario( }, { interval: 2_000, timeout: 35_000 }, ) - .toMatch(new RegExp(`^openshell:resolve:env:v[0-9]+_${credentialKey}$`)); + .toMatch(placeholderPattern); expect(placeholderAfterRotation).not.toBe(placeholder); for (const secret of redactionValues) expect(placeholderAfterRotation).not.toContain(secret); await expectProtectedResourceVersion( diff --git a/test/e2e/support/hosted-inference.test.ts b/test/e2e/support/hosted-inference.test.ts index d1c698d636e..2b2eb1c635b 100644 --- a/test/e2e/support/hosted-inference.test.ts +++ b/test/e2e/support/hosted-inference.test.ts @@ -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"; @@ -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" }),