From 197782cdd27ce38b24f674007206933dca5e5b93 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Mon, 27 Jul 2026 21:49:17 -0700 Subject: [PATCH 01/10] feat(identity): add host-managed Okta OBO reference Signed-off-by: Alex Fournier --- docs/reference/architecture.mdx | 2 +- docs/reference/configure-runtime-identity.mdx | 84 +++- .../provider-profiles/okta-obo-v1.yaml | 32 ++ .../src/blueprint/runtime-identity.test.ts | 181 ++++++- nemoclaw/src/blueprint/runtime-identity.ts | 472 +++++++++++++----- schemas/blueprint.schema.json | 58 ++- ...ueprint-runtime-identity-lifecycle.test.ts | 94 +++- .../blueprint-runtime-identity-schema.test.ts | 32 ++ test/docs-refactor-ownership.test.ts | 6 +- test/e2e/live/inference-routing.test.ts | 2 + 10 files changed, 800 insertions(+), 163 deletions(-) create mode 100644 nemoclaw-blueprint/provider-profiles/okta-obo-v1.yaml diff --git a/docs/reference/architecture.mdx b/docs/reference/architecture.mdx index fc34d1dadb9..1aceb2053dc 100644 --- a/docs/reference/architecture.mdx +++ b/docs/reference/architecture.mdx @@ -271,7 +271,7 @@ flowchart LR The direct OpenClaw blueprint runner can opt in to a provider-neutral runtime identity component. The shipped blueprint and normal `nemoclaw onboard` do not enable or configure this experimental capability. -Refer to [Configure Experimental Runtime Identity](configure-runtime-identity) for the trust boundary, Okta and Microsoft Entra reference profiles, apply lifecycle, inspection, and rollback behavior. +Refer to [Configure Experimental Runtime Identity](configure-runtime-identity) for the trust boundary, Okta refresh, Okta OBO, Microsoft Entra, inspection, and rollback behavior. Runtime identity is an experimental capability of the direct OpenClaw blueprint runner. diff --git a/docs/reference/configure-runtime-identity.mdx b/docs/reference/configure-runtime-identity.mdx index 0c803cbbc67..550662f45d0 100644 --- a/docs/reference/configure-runtime-identity.mdx +++ b/docs/reference/configure-runtime-identity.mdx @@ -4,8 +4,8 @@ title: "Configure Experimental Runtime Identity" sidebar-title: "Configure Runtime Identity" description: "Configure the experimental provider-neutral runtime identity component for a direct OpenClaw blueprint." -description-agent: "Configures the direct OpenClaw blueprint runner's experimental runtime identity component, including its Okta and Microsoft Entra profiles, trust boundary, lifecycle, inspection, and rollback. Use when operating components.identity outside normal onboarding." -keywords: ["nemoclaw runtime identity", "openclaw runtime identity", "okta runtime identity", "microsoft entra runtime identity"] +description-agent: "Configures the direct OpenClaw blueprint runner's experimental runtime identity component, including its Okta refresh, Okta on-behalf-of, and Microsoft Entra profiles. Use when operating components.identity outside normal onboarding." +keywords: ["nemoclaw runtime identity", "openclaw runtime identity", "okta runtime identity", "okta obo", "microsoft entra runtime identity"] agent-variants: ["openclaw"] content: type: "how_to" @@ -14,7 +14,7 @@ content: The direct OpenClaw blueprint runner can attach a provider-neutral runtime identity to a sandbox. The shipped blueprint and normal `nemoclaw onboard` do not enable or configure this experimental capability. -The bundled Okta and Microsoft Entra profiles are data-only implementations of the component. +The bundled Okta, Okta on-behalf-of (OBO), and Microsoft Entra profiles are data-only implementations of the component. The blueprint schema describes the OpenShell provider binding and OAuth refresh inputs without using the identity provider as a schema discriminator. ## Review the Trust Boundary @@ -26,18 +26,19 @@ The host process that supplies the named OAuth bootstrap variables and the authe The blueprint, copied profile, same-name gateway resources, sandbox workload, subprocess output, persisted state, and CI artifacts are untrusted or observable surfaces. The runner validates the complete data-only profile before import. -It fails closed on ambiguous resource inspection, scopes secret material to one refresh-configuration subprocess, and persists only non-secret ownership receipts. -OpenShell owns credential custody, refresh, admitted-request enforcement, and bearer substitution. +It fails closed on ambiguous resource inspection, scopes credential material to one required subprocess, and persists only non-secret ownership receipts. +OpenShell owns credential custody, admitted-request enforcement, and bearer substitution. +It also owns refresh for profiles that configure gateway-managed refresh. NemoClaw never exposes the minted bearer to the sandbox. -Runtime identity profiles may use DNS-backed HTTPS only within a provider type's repository-reviewed hostname suffixes. +Token endpoints may use DNS-backed HTTPS only within a provider type's repository-reviewed hostname suffixes. NemoClaw resolves every destination before import and rejects private or internal addresses. OpenShell performs connect-time server-side request forgery (SSRF) and L7 enforcement before it injects the provider credential. -OpenShell 0.0.85 verifies the original hostname's certificate but does not pin the address that NemoClaw resolved. -Only identity-platform-controlled DNS namespaces such as `okta.com`, `login.microsoftonline.com`, and `graph.microsoft.com` belong in this trust table. -An attacker-controlled or customer-controlled suffix requires a pinning-capable upstream refresh boundary and new conformance evidence. +The OBO profile permits a configured public HTTPS resource host after NemoClaw SSRF validation. +The OpenShell proxy resolves that host again and applies its SSRF policy before the upstream connection. +The runner still restricts the OBO token endpoint to `okta.com` or `oktapreview.com`. ### Review the Conformance Evidence @@ -122,7 +123,7 @@ Private, loopback, link-local, and unresolved destinations are rejected. DNS-backed destinations require a reviewed policy that marks their namespace as identity-platform-controlled. Other profile policies reject them before import. -The current `okta-runtime-v1` policy accepts `okta.com` and its subdomains. +The current `okta-runtime-v1` policy accepts `okta.com`, `oktapreview.com`, and their subdomains. Custom Okta domains are not supported. The reviewed policy fixes the non-secret client ID source to `OKTA_CLIENT_ID`. @@ -140,6 +141,56 @@ After apply succeeds, run `unset OKTA_REFRESH_TOKEN OKTA_CLIENT_SECRET` in the h OpenShell retains the refresh material in the gateway credential store until rollback or provider deletion removes the owned provider. +## Configure the Okta On-Behalf-Of Reference Profile + +The `okta-obo-v1` profile lets a sandbox call an API with a short-lived token delegated from a signed-in Okta user. +NemoClaw performs one OAuth 2.0 token exchange on the host. +It then creates a standard OpenShell provider with the returned token and attaches that provider to the sandbox. +This flow uses the current OpenShell provider API and does not require an OpenShell token-exchange strategy. + +Copy `nemoclaw-blueprint/provider-profiles/okta-obo-v1.yaml` into your blueprint as `provider-profiles/acme-okta-obo.yaml`. +Replace `api.example.com` with the public HTTPS API host that accepts the delegated token. +Keep the endpoint rule as narrow as the API allows. + +Configure the reference under `components`. + +```yaml +identity: + profile_path: provider-profiles/acme-okta-obo.yaml + provider_type: okta-obo-v1 + provider_name: acme-okta-obo + credential_key: OKTA_OBO_ACCESS_TOKEN + client_id_env: OKTA_CLIENT_ID + client_secret_env: OKTA_CLIENT_SECRET + subject_token_env: OKTA_SUBJECT_TOKEN + token_url: https://example.okta.com/oauth2/default/v1/token + audience: api://orders + scopes: + - orders.read +``` + +`OKTA_SUBJECT_TOKEN` must contain a current access token for the signed-in user. +The client ID and client secret identify the confidential Okta client authorized to perform the exchange. +The runner sends an RFC 8693 token-exchange request to the configured Okta token endpoint. +It rejects redirects, URL credentials, non-HTTPS endpoints, private addresses, and token hosts outside `okta.com` or `oktapreview.com`. + +The runner does not put the subject token, client secret, or delegated token in command arguments, plans, or NemoClaw state. +It passes the delegated token only through the environment of one `openshell provider create` subprocess. +OpenShell stores the delegated token and gives the sandbox an opaque `OKTA_OBO_ACCESS_TOKEN` placeholder. +The OpenShell proxy rewrites that placeholder only for an admitted request to the configured resource endpoint. + + +The host process can read `OKTA_SUBJECT_TOKEN` and `OKTA_CLIENT_SECRET` until you unset them or the process exits. +After apply succeeds, run `unset OKTA_SUBJECT_TOKEN OKTA_CLIENT_SECRET` in the host shell. +OpenShell retains the delegated token until rollback or provider deletion removes the provider. +The protected API rejects the token after its Okta expiration time. + + +This reference performs one exchange during each apply. +It does not renew the delegated token or select a token for each user at request time. +When the token expires, roll back the owned provider and apply again with a current subject token. +A long-running or multi-user deployment requires a separate credential broker or a future OpenShell dynamic credential source. + ## Configure the Microsoft Entra Reference Profile Copy `nemoclaw-blueprint/provider-profiles/entra-runtime-v1.yaml` into your blueprint as `provider-profiles/acme-entra-runtime.yaml`. @@ -223,14 +274,15 @@ It never reuses that provider because OpenShell does not expose a secret-safe sn Only an absent provider proceeds to profile import. When the profile is already registered, the runner first requires an exact export match. -It then creates the provider, configures gateway-managed OAuth refresh, and performs the initial token rotation. +For a refresh profile, the runner creates the provider, configures gateway-managed OAuth refresh, and performs the initial token rotation. +For the OBO profile, the host exchanges the subject token and creates a static provider with the returned token. Before attachment, the runner creates or reuses the configured inference provider. For a reused sandbox and provider, it preserves the live route only when the provider, model, and requested timeout match the blueprint. An absent or different route must pass `openshell inference set`. The runner attaches the runtime identity and applies policy additions only after OpenShell confirms the route. -Each successful apply creates and owns the runtime identity provider whose refresh state it mutates. +Each successful apply creates and owns the runtime identity provider. The persisted plan records ownership of the runtime identity provider, sandbox attachment, inference provider, and sandbox. The runner writes the receipt as it acquires each resource. The receipt preserves a recovery path when automatic compensation fails. @@ -243,7 +295,10 @@ It passes them to `openshell provider refresh configure` through a scoped subpro OpenShell stores the credential material in the gateway credential store. Other runner subprocesses receive the allowlisted environment without identity material. -Each sandbox child launch receives an opaque provider-specific placeholder such as `OKTA_ACCESS_TOKEN` or `ENTRA_ACCESS_TOKEN`. +The OBO path gives only the delegated token to its scoped `openshell provider create` subprocess. +It does not give that subprocess the subject token or Okta client secret. + +Each sandbox child launch receives an opaque provider-specific placeholder such as `OKTA_ACCESS_TOKEN`, `OKTA_OBO_ACCESS_TOKEN`, or `ENTRA_ACCESS_TOKEN`. The OpenShell L7 proxy substitutes the corresponding access token only for admitted HTTPS requests. After rotation, launch a new child process to receive the new revision-scoped placeholder. An earlier child process does not adopt the rotation. @@ -254,6 +309,7 @@ Check gateway-side state without printing credential values. ```bash openshell provider refresh status acme-okta-runtime --credential-key OKTA_ACCESS_TOKEN +openshell provider get acme-okta-obo openshell sandbox provider list ``` @@ -278,7 +334,7 @@ Rollback does not mutate a same-name provider when its binding no longer matches Runtime identity does not add a generic blueprint middleware surface. Configure deployment-specific pre-credential policy through separately supported OpenShell tooling. -This reference does not package an OAuth bootstrap application, an on-behalf-of exchange, or a production identity middleware service. +These references do not package an OAuth bootstrap application, automatic OBO renewal, or a production identity middleware service. ## Related Topics diff --git a/nemoclaw-blueprint/provider-profiles/okta-obo-v1.yaml b/nemoclaw-blueprint/provider-profiles/okta-obo-v1.yaml new file mode 100644 index 00000000000..ea52663a6bf --- /dev/null +++ b/nemoclaw-blueprint/provider-profiles/okta-obo-v1.yaml @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Data-only profile for the experimental host-managed Okta OBO reference. +# NemoClaw exchanges the user subject token before this provider is created. +# OpenShell stores the resulting short-lived token and injects it only for an +# admitted request; the sandbox receives an opaque placeholder instead. +id: okta-obo-v1 +display_name: Okta OBO Runtime Credentials v1 +description: Short-lived Okta delegated access token for an attached sandbox +category: agent +credentials: + - name: OKTA_OBO_ACCESS_TOKEN + description: Short-lived Okta delegated access token + env_vars: + - OKTA_OBO_ACCESS_TOKEN + required: true + auth_style: bearer + header_name: authorization +endpoints: + - host: api.example.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: GET, path: "/**" } +binaries: + - /usr/local/bin/node + - /usr/bin/node + - /usr/local/bin/curl + - /usr/bin/curl +inference_capable: false diff --git a/nemoclaw/src/blueprint/runtime-identity.test.ts b/nemoclaw/src/blueprint/runtime-identity.test.ts index 3f975f03127..495a6588049 100644 --- a/nemoclaw/src/blueprint/runtime-identity.test.ts +++ b/nemoclaw/src/blueprint/runtime-identity.test.ts @@ -14,12 +14,13 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { attachRuntimeIdentity, buildRuntimeIdentityPlan, compensateRuntimeIdentityApply, + exchangeOktaOboToken, isRuntimeIdentityConfig, isRuntimeIdentityReceipt, parseRuntimeIdentityProviderMetadata, @@ -125,6 +126,48 @@ const entraConfig: RuntimeIdentityConfig = { client_secret_env: "ENTRA_CLIENT_SECRET", }; +const oboProfileDocument = [ + "id: okta-obo-v1", + "display_name: Okta OBO Runtime Credentials v1", + "description: Short-lived Okta delegated access token for an attached sandbox", + "category: agent", + "credentials:", + " - name: OKTA_OBO_ACCESS_TOKEN", + " description: Short-lived Okta delegated access token", + " env_vars:", + " - OKTA_OBO_ACCESS_TOKEN", + " required: true", + " auth_style: bearer", + " header_name: authorization", + "endpoints:", + " - host: api.example.com", + " port: 443", + " protocol: rest", + " enforcement: enforce", + " rules:", + ' - allow: { method: GET, path: "/**" }', + "binaries:", + " - /usr/local/bin/node", + " - /usr/bin/node", + " - /usr/local/bin/curl", + " - /usr/bin/curl", + "inference_capable: false", + "", +].join("\n"); + +const oboConfig: RuntimeIdentityConfig = { + profile_path: "provider-profiles/okta-obo-v1.yaml", + provider_type: "okta-obo-v1", + provider_name: "acme-okta-obo", + credential_key: "OKTA_OBO_ACCESS_TOKEN", + client_id_env: "OKTA_CLIENT_ID", + subject_token_env: "OKTA_SUBJECT_TOKEN", + client_secret_env: "OKTA_CLIENT_SECRET", + token_url: "https://nvidia-partner.oktapreview.com/oauth2/default/v1/token", + audience: "api://orders", + scopes: ["orders.read"], +}; + const createdReceipt: RuntimeIdentityReceipt = { provider_type: config.provider_type, provider_name: config.provider_name, @@ -213,6 +256,16 @@ describe("runtime identity contract", () => { expect(JSON.stringify(buildRuntimeIdentityPlan(config))).not.toContain("OKTA_CLIENT"); }); + it("accepts an Okta OBO config without placing exchange material in the plan", () => { + expect(isRuntimeIdentityConfig(oboConfig)).toBe(true); + expect(buildRuntimeIdentityPlan(oboConfig)).toEqual({ + provider_type: "okta-obo-v1", + provider_name: "acme-okta-obo", + credential_key: "OKTA_OBO_ACCESS_TOKEN", + }); + expect(JSON.stringify(buildRuntimeIdentityPlan(oboConfig))).not.toContain("subject"); + }); + it.each([ null, {}, @@ -231,6 +284,12 @@ describe("runtime identity contract", () => { { ...config, client_id_env: "XDG_CLIENT_ID" }, { ...config, refresh_token_env: "MYTOKEN" }, { ...config, client_secret_env: "OPENSHELL_CONFIG" }, + { ...oboConfig, refresh_token_env: "OKTA_REFRESH_TOKEN" }, + { ...oboConfig, subject_token_env: "OKTA_CLIENT_ID" }, + { ...oboConfig, client_secret_env: undefined }, + { ...oboConfig, token_url: "" }, + { ...oboConfig, scopes: [] }, + { ...oboConfig, scopes: ["orders read"] }, ])("rejects an invalid provider-neutral config: %j", (value) => { expect(isRuntimeIdentityConfig(value)).toBe(false); }); @@ -339,6 +398,122 @@ describe("runtime identity contract", () => { expect(existsSync(importedProfilePaths[0])).toBe(false); }); + it("exchanges an Okta subject token on the host and creates a static injected provider", async () => { + writeFileSync(join(root, oboConfig.profile_path), oboProfileDocument); + environment.OKTA_SUBJECT_TOKEN = "user-subject-token"; + let exchangeRequest: unknown; + deps.exchangeToken = async (request) => { + exchangeRequest = request; + return "delegated-access-token"; + }; + responses.set("provider get acme-okta-obo", [missingProvider]); + + await expect(prepareRuntimeIdentity(oboConfig, deps)).resolves.toEqual({ + provider_type: "okta-obo-v1", + provider_name: "acme-okta-obo", + credential_key: "OKTA_OBO_ACCESS_TOKEN", + provider_created: true, + attachment_created: false, + }); + + expect(exchangeRequest).toEqual({ + tokenUrl: "https://nvidia-partner.oktapreview.com/oauth2/default/v1/token", + clientId: "client-id", + clientSecret: "client-secret", + subjectToken: "user-subject-token", + audience: "api://orders", + scopes: ["orders.read"], + }); + expect(calls.map(({ args }) => commandKey(args))).toEqual([ + "settings get --global --json", + "provider get acme-okta-obo", + "provider profile import --file", + "provider create --name acme-okta-obo --type okta-obo-v1 --credential OKTA_OBO_ACCESS_TOKEN", + ]); + expect(calls.flatMap(({ args }) => args)).not.toContain("delegated-access-token"); + expect(calls.at(-1)?.env).toEqual({ OKTA_OBO_ACCESS_TOKEN: "delegated-access-token" }); + expect(validatedDestinations).toEqual([ + "https://api.example.com/", + "https://nvidia-partner.oktapreview.com/oauth2/default/v1/token", + ]); + }); + + it("rejects an untrusted token issuer before it mutates OpenShell state", async () => { + writeFileSync(join(root, oboConfig.profile_path), oboProfileDocument); + environment.OKTA_SUBJECT_TOKEN = "user-subject-token"; + + await expect( + prepareRuntimeIdentity( + { + ...oboConfig, + token_url: "https://nvidia-partner.oktapreview.com.attacker.example/token", + }, + deps, + ), + ).rejects.toThrow(/outside the trusted destination policy/); + expect(calls).toEqual([]); + }); + + it("rejects an unsafe public OBO resource before it mutates OpenShell state", async () => { + writeFileSync(join(root, oboConfig.profile_path), oboProfileDocument); + environment.OKTA_SUBJECT_TOKEN = "user-subject-token"; + deps.validateEndpointUrl = async (url) => + url.includes("api.example.com") + ? Promise.reject(new Error("private destination rejected")) + : Promise.resolve({ dnsResolved: false }); + + await expect(prepareRuntimeIdentity(oboConfig, deps)).rejects.toThrow( + /private destination rejected/, + ); + expect(calls).toEqual([]); + }); + + it("uses RFC 8693 with client authentication and does not disclose an error response", async () => { + const fetchImpl = vi.fn( + async (_url: string, _options: RequestInit) => + new Response(JSON.stringify({ access_token: "delegated-access-token" }), { status: 200 }), + ); + await expect( + exchangeOktaOboToken( + { + tokenUrl: "https://example.okta.com/oauth2/default/v1/token", + clientId: "client-id", + clientSecret: "client-secret", + subjectToken: "user-subject-token", + audience: "api://orders", + scopes: ["orders.read"], + }, + fetchImpl, + ), + ).resolves.toBe("delegated-access-token"); + + const [url, options] = fetchImpl.mock.calls[0] ?? []; + expect(url).toBe("https://example.okta.com/oauth2/default/v1/token"); + expect(options).toMatchObject({ method: "POST", redirect: "error" }); + const headers = options?.headers as Record; + expect(headers["cache-control"]).toBe("no-store"); + expect(headers.authorization).toBe( + `Basic ${Buffer.from("client-id:client-secret").toString("base64")}`, + ); + const form = new URLSearchParams(options?.body?.toString()); + expect(form.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:token-exchange"); + expect(form.get("subject_token")).toBe("user-subject-token"); + + await expect( + exchangeOktaOboToken( + { + tokenUrl: "https://example.okta.com/oauth2/default/v1/token", + clientId: "client-id", + clientSecret: "client-secret", + subjectToken: "user-subject-token", + audience: "api://orders", + scopes: ["orders.read"], + }, + async () => new Response("server echoed a secret", { status: 400 }), + ), + ).rejects.toThrow("Okta token exchange failed with HTTP 400"); + }); + it("fails before identity mutation when provider-derived policy is disabled", async () => { responses.set("settings get --global --json", [ { @@ -557,6 +732,7 @@ describe("runtime identity contract", () => { profilePolicy: { providerType: "okta-runtime-v1", clientIdEnvironmentName: "OKTA_CLIENT_ID", + flow: "oauth2-refresh-token", dnsResolution: "reject", tokenIssuer: { trustedHostnames: [], @@ -565,6 +741,7 @@ describe("runtime identity contract", () => { credentialDelivery: { method: "GET", path: "/**", + hostPolicy: "reviewed", trustedHostnames: [], trustedHostSuffixes: ["okta.com"], }, @@ -616,6 +793,7 @@ describe("runtime identity contract", () => { profilePolicy: { providerType: "oauth2-runtime-conformance-v1", clientIdEnvironmentName: "E2E_CLIENT_ID", + flow: "oauth2-refresh-token", dnsResolution: "identity-platform-controlled", tokenIssuer: { trustedHostnames: ["identity-fixture.trycloudflare.com"], @@ -624,6 +802,7 @@ describe("runtime identity contract", () => { credentialDelivery: { method: "GET", path: "/**", + hostPolicy: "reviewed", trustedHostnames: ["identity-fixture.trycloudflare.com"], trustedHostSuffixes: [], }, diff --git a/nemoclaw/src/blueprint/runtime-identity.ts b/nemoclaw/src/blueprint/runtime-identity.ts index 0a579d2a776..44f080ae39c 100644 --- a/nemoclaw/src/blueprint/runtime-identity.ts +++ b/nemoclaw/src/blueprint/runtime-identity.ts @@ -75,14 +75,26 @@ const RUNTIME_IDENTITY_ENDPOINT_KEYS = Object.freeze([ "rules", ]); +const OBO_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; +const OBO_ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; +const MAX_OBO_AUDIENCE_LENGTH = 1024; +const MAX_OBO_SCOPES = 32; +const MAX_OBO_SCOPE_LENGTH = 256; +const MAX_OBO_ACCESS_TOKEN_LENGTH = 16 * 1024; +const OBO_SCOPE_PATTERN = /^[A-Za-z0-9:._/-]+$/; + export interface RuntimeIdentityConfig { profile_path: string; provider_type: string; provider_name: string; credential_key: string; client_id_env: string; - refresh_token_env: string; + refresh_token_env?: string; + subject_token_env?: string; client_secret_env?: string; + token_url?: string; + audience?: string; + scopes?: readonly string[]; } export interface RuntimeIdentityPlan { @@ -123,6 +135,7 @@ export interface RuntimeIdentityValidatedDestination { export interface RuntimeIdentityProfilePolicy { providerType: string; clientIdEnvironmentName: string; + flow: "oauth2-refresh-token" | "oauth2-token-exchange"; dnsResolution: "reject" | "identity-platform-controlled"; tokenIssuer: { trustedHostnames: readonly string[]; @@ -131,6 +144,7 @@ export interface RuntimeIdentityProfilePolicy { credentialDelivery: { method: "GET"; path: string; + hostPolicy: "reviewed" | "public-https"; trustedHostnames: readonly string[]; trustedHostSuffixes: readonly string[]; }; @@ -142,22 +156,43 @@ const RUNTIME_IDENTITY_PROFILE_POLICIES: Readonly Promise; + export interface RuntimeIdentityDeps extends RuntimeIdentityCommandDeps { validateEndpointUrl(url: string): Promise; persistReceipt(receipt: RuntimeIdentityReceipt): void; + exchangeToken?(request: RuntimeIdentityTokenExchangeRequest): Promise; profilePolicy?: RuntimeIdentityProfilePolicy; } @@ -277,13 +325,7 @@ function requireTrustedProfileHostname( } } -function requireHttpsDestination( - value: string, - trustedHostnames: readonly string[], - trustedHostSuffixes: readonly string[], - providerType: string, - label: string, -): string { +function requireHttpsUrl(value: string, label: string): URL { if (value.length === 0 || value.length > MAX_DESTINATION_URL_LENGTH) { throw new Error(`${label} must be a non-empty URL no longer than 2048 characters`); } @@ -299,6 +341,17 @@ function requireHttpsDestination( if (parsed.username !== "" || parsed.password !== "") { throw new Error(`${label} must not include URL credentials`); } + return parsed; +} + +function requireHttpsDestination( + value: string, + trustedHostnames: readonly string[], + trustedHostSuffixes: readonly string[], + providerType: string, + label: string, +): string { + const parsed = requireHttpsUrl(value, label); requireTrustedProfileHostname( parsed.hostname, trustedHostnames, @@ -334,13 +387,15 @@ function parseRuntimeIdentityEndpoint( if (unbracketedHost.includes("[") || unbracketedHost.includes("]")) { throw new Error(`${label} must declare a valid host`); } - requireTrustedProfileHostname( - unbracketedHost, - policy.credentialDelivery.trustedHostnames, - policy.credentialDelivery.trustedHostSuffixes, - policy.providerType, - `${label} credential delivery`, - ); + if (policy.credentialDelivery.hostPolicy === "reviewed") { + requireTrustedProfileHostname( + unbracketedHost, + policy.credentialDelivery.trustedHostnames, + policy.credentialDelivery.trustedHostSuffixes, + policy.providerType, + `${label} credential delivery`, + ); + } if ( protocol !== "rest" || enforcement !== "enforce" || @@ -359,14 +414,18 @@ function parseRuntimeIdentityEndpoint( ); } const urlHost = unbracketedHost.includes(":") ? `[${unbracketedHost}]` : unbracketedHost; + const destination = `https://${urlHost}:${String(port)}/`; return { - destination: requireHttpsDestination( - `https://${urlHost}:${String(port)}/`, - policy.credentialDelivery.trustedHostnames, - policy.credentialDelivery.trustedHostSuffixes, - policy.providerType, - label, - ), + destination: + policy.credentialDelivery.hostPolicy === "public-https" + ? requireHttpsUrl(destination, label).toString() + : requireHttpsDestination( + destination, + policy.credentialDelivery.trustedHostnames, + policy.credentialDelivery.trustedHostSuffixes, + policy.providerType, + label, + ), document: { host, port, @@ -437,28 +496,41 @@ function parseRuntimeIdentityProfile( throw new Error(`${label} credential presentation policy is not supported`); } const refresh = credentials[0].refresh; - if ( - !isPlainObject(refresh) || - !hasOnlyKeys(refresh, RUNTIME_IDENTITY_REFRESH_KEYS) || - refresh.strategy !== "oauth2_refresh_token" || - typeof refresh.token_url !== "string" || - !Number.isInteger(refresh.refresh_before_seconds) || - (refresh.refresh_before_seconds as number) < 60 || - (refresh.refresh_before_seconds as number) > 3600 || - !Number.isInteger(refresh.max_lifetime_seconds) || - (refresh.max_lifetime_seconds as number) <= (refresh.refresh_before_seconds as number) || - (refresh.max_lifetime_seconds as number) > 86400 - ) { - throw new Error(`${label} must declare a refresh token_url`); + let canonicalRefresh: Record | undefined; + let refreshDestination: string | undefined; + if (policy.flow === "oauth2-refresh-token") { + if ( + !isPlainObject(refresh) || + !hasOnlyKeys(refresh, RUNTIME_IDENTITY_REFRESH_KEYS) || + refresh.strategy !== "oauth2_refresh_token" || + typeof refresh.token_url !== "string" || + !Number.isInteger(refresh.refresh_before_seconds) || + (refresh.refresh_before_seconds as number) < 60 || + (refresh.refresh_before_seconds as number) > 3600 || + !Number.isInteger(refresh.max_lifetime_seconds) || + (refresh.max_lifetime_seconds as number) <= (refresh.refresh_before_seconds as number) || + (refresh.max_lifetime_seconds as number) > 86400 + ) { + throw new Error(`${label} must declare a refresh token_url`); + } + const material = requireExactRefreshMaterial(refresh.material, `${label} refresh material`); + refreshDestination = requireHttpsDestination( + refresh.token_url, + policy.tokenIssuer.trustedHostnames, + policy.tokenIssuer.trustedHostSuffixes, + policy.providerType, + `${label} refresh token_url`, + ); + canonicalRefresh = { + strategy: "oauth2_refresh_token", + token_url: refreshDestination, + refresh_before_seconds: refresh.refresh_before_seconds, + max_lifetime_seconds: refresh.max_lifetime_seconds, + material, + }; + } else if (refresh !== undefined) { + throw new Error(`${label} must not declare OpenShell-managed refresh for token exchange`); } - const material = requireExactRefreshMaterial(refresh.material, `${label} refresh material`); - const tokenUrl = requireHttpsDestination( - refresh.token_url, - policy.tokenIssuer.trustedHostnames, - policy.tokenIssuer.trustedHostSuffixes, - policy.providerType, - `${label} refresh token_url`, - ); const endpoints = parsed.endpoints; if ( !Array.isArray(endpoints) || @@ -487,20 +559,17 @@ function parseRuntimeIdentityProfile( required: true, auth_style: "bearer", header_name: "authorization", - refresh: { - strategy: "oauth2_refresh_token", - token_url: tokenUrl, - refresh_before_seconds: refresh.refresh_before_seconds, - max_lifetime_seconds: refresh.max_lifetime_seconds, - material, - }, + ...(canonicalRefresh === undefined ? {} : { refresh: canonicalRefresh }), }, ], endpoints: parsedEndpoints.map(({ document }) => document), binaries, inference_capable: false, }, - destinations: [tokenUrl, ...parsedEndpoints.map(({ destination }) => destination)], + destinations: [ + ...(refreshDestination === undefined ? [] : [refreshDestination]), + ...parsedEndpoints.map(({ destination }) => destination), + ], }; } @@ -572,26 +641,42 @@ export function parseRuntimeIdentityProviderMetadata( } export function isRuntimeIdentityConfig(value: unknown): value is RuntimeIdentityConfig { - if ( - !isPlainObject(value) || - !hasOnlyKeys(value, [ - "profile_path", - "provider_type", - "provider_name", - "credential_key", - "client_id_env", - "refresh_token_env", - "client_secret_env", - ]) - ) { + if (!isPlainObject(value)) { return false; } + const isRefreshFlow = typeof value.refresh_token_env === "string"; + const isTokenExchangeFlow = typeof value.subject_token_env === "string"; + const allowedKeys = isRefreshFlow + ? [ + "profile_path", + "provider_type", + "provider_name", + "credential_key", + "client_id_env", + "refresh_token_env", + "client_secret_env", + ] + : [ + "profile_path", + "provider_type", + "provider_name", + "credential_key", + "client_id_env", + "subject_token_env", + "client_secret_env", + "token_url", + "audience", + "scopes", + ]; + if (!hasOnlyKeys(value, allowedKeys) || isRefreshFlow === isTokenExchangeFlow) return false; + const credentialEnvironmentNames = [ value.client_id_env, value.refresh_token_env, + value.subject_token_env, value.client_secret_env, ].filter((name): name is string => typeof name === "string"); - return ( + const commonValid = typeof value.profile_path === "string" && value.profile_path.trim().length > 0 && typeof value.provider_type === "string" && @@ -603,15 +688,43 @@ export function isRuntimeIdentityConfig(value: unknown): value is RuntimeIdentit typeof value.client_id_env === "string" && ENV_NAME_PATTERN.test(value.client_id_env) && CLIENT_ID_ENV_PATTERN.test(value.client_id_env) && - typeof value.refresh_token_env === "string" && - ENV_NAME_PATTERN.test(value.refresh_token_env) && - SECRET_MATERIAL_ENV_PATTERN.test(value.refresh_token_env) && (value.client_secret_env === undefined || (typeof value.client_secret_env === "string" && ENV_NAME_PATTERN.test(value.client_secret_env) && SECRET_MATERIAL_ENV_PATTERN.test(value.client_secret_env))) && new Set(credentialEnvironmentNames).size === credentialEnvironmentNames.length && - credentialEnvironmentNames.every((name) => !isSubprocessEnvNameAllowed(name)) + credentialEnvironmentNames.every((name) => !isSubprocessEnvNameAllowed(name)); + if (!commonValid) return false; + + if (isRefreshFlow) { + return ( + typeof value.refresh_token_env === "string" && + ENV_NAME_PATTERN.test(value.refresh_token_env) && + SECRET_MATERIAL_ENV_PATTERN.test(value.refresh_token_env) + ); + } + + return ( + typeof value.subject_token_env === "string" && + ENV_NAME_PATTERN.test(value.subject_token_env) && + SECRET_MATERIAL_ENV_PATTERN.test(value.subject_token_env) && + typeof value.client_secret_env === "string" && + typeof value.token_url === "string" && + value.token_url.length > 0 && + typeof value.audience === "string" && + value.audience.trim().length > 0 && + value.audience.length <= MAX_OBO_AUDIENCE_LENGTH && + !UNSAFE_CONTROL_PATTERN.test(value.audience) && + Array.isArray(value.scopes) && + value.scopes.length > 0 && + value.scopes.length <= MAX_OBO_SCOPES && + value.scopes.every( + (scope) => + typeof scope === "string" && + scope.length <= MAX_OBO_SCOPE_LENGTH && + OBO_SCOPE_PATTERN.test(scope), + ) && + new Set(value.scopes).size === value.scopes.length ); } @@ -694,6 +807,86 @@ async function requireProviderDerivedPolicy(deps: RuntimeIdentityCommandDeps): P } } +function requireOktaOBOConfig( + config: RuntimeIdentityConfig, + policy: RuntimeIdentityProfilePolicy, + deps: RuntimeIdentityDeps, +): Promise { + if ( + policy.flow !== "oauth2-token-exchange" || + typeof config.token_url !== "string" || + typeof config.audience !== "string" || + !Array.isArray(config.scopes) + ) { + throw new Error("Runtime identity token exchange configuration is invalid"); + } + const tokenUrl = requireHttpsDestination( + config.token_url, + policy.tokenIssuer.trustedHostnames, + policy.tokenIssuer.trustedHostSuffixes, + policy.providerType, + "Runtime identity token exchange token_url", + ); + return deps.validateEndpointUrl(tokenUrl).then((validated) => { + if (validated.dnsResolved && policy.dnsResolution !== "identity-platform-controlled") { + throw new Error( + `DNS-backed runtime identity destination '${tokenUrl}' is outside the reviewed ` + + `DNS policy for '${policy.providerType}'`, + ); + } + return tokenUrl; + }); +} + +export async function exchangeOktaOboToken( + request: RuntimeIdentityTokenExchangeRequest, + fetchImpl: RuntimeIdentityFetch = fetch, +): Promise { + const form = new URLSearchParams({ + grant_type: OBO_GRANT_TYPE, + subject_token: request.subjectToken, + subject_token_type: OBO_ACCESS_TOKEN_TYPE, + requested_token_type: OBO_ACCESS_TOKEN_TYPE, + audience: request.audience, + scope: request.scopes.join(" "), + }); + let response: Response; + try { + response = await fetchImpl(request.tokenUrl, { + method: "POST", + headers: { + authorization: `Basic ${Buffer.from(`${request.clientId}:${request.clientSecret}`).toString("base64")}`, + "content-type": "application/x-www-form-urlencoded", + "cache-control": "no-store", + }, + body: form, + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + } catch { + throw new Error("Okta token exchange request failed"); + } + if (!response.ok) { + throw new Error(`Okta token exchange failed with HTTP ${String(response.status)}`); + } + let document: unknown; + try { + document = await response.json(); + } catch { + throw new Error("Okta token exchange response was not valid JSON"); + } + if ( + !isPlainObject(document) || + typeof document.access_token !== "string" || + document.access_token.length === 0 || + document.access_token.length > MAX_OBO_ACCESS_TOKEN_LENGTH || + UNSAFE_CONTROL_PATTERN.test(document.access_token) + ) { + throw new Error("Okta token exchange response did not contain a safe access token"); + } + return document.access_token; +} + export function resolveRuntimeIdentityProfilePath( profilePath: string, blueprintPath: string, @@ -844,10 +1037,21 @@ export async function prepareRuntimeIdentity( ); await validateProfileDestinations(requestedProfile, profilePolicy, deps); const clientId = requiredEnvironmentValue(config, config.client_id_env, env); - const refreshToken = requiredEnvironmentValue(config, config.refresh_token_env, env); + const isRefreshFlow = profilePolicy.flow === "oauth2-refresh-token"; + const refreshToken = + isRefreshFlow && config.refresh_token_env + ? requiredEnvironmentValue(config, config.refresh_token_env, env) + : undefined; const clientSecret = config.client_secret_env ? requiredEnvironmentValue(config, config.client_secret_env, env) : undefined; + const subjectToken = + !isRefreshFlow && config.subject_token_env + ? requiredEnvironmentValue(config, config.subject_token_env, env) + : undefined; + const tokenExchangeUrl = isRefreshFlow + ? undefined + : await requireOktaOBOConfig(config, profilePolicy, deps); await requireProviderDerivedPolicy(deps); const plan = buildRuntimeIdentityPlan(config); const providerState = await inspectProvider(plan, deps); @@ -899,6 +1103,21 @@ export async function prepareRuntimeIdentity( } } + let oboAccessToken: string | undefined; + if (!isRefreshFlow) { + if (!subjectToken || !clientSecret || !tokenExchangeUrl || !config.audience || !config.scopes) { + throw new Error("Runtime identity token exchange configuration is invalid"); + } + oboAccessToken = await (deps.exchangeToken ?? exchangeOktaOboToken)({ + tokenUrl: tokenExchangeUrl, + clientId, + clientSecret, + subjectToken, + audience: config.audience, + scopes: config.scopes, + }); + } + const receipt: RuntimeIdentityReceipt = { ...plan, provider_created: true, @@ -906,66 +1125,73 @@ export async function prepareRuntimeIdentity( }; let providerAcquired = false; try { - const providerCreate = await deps.run([ - "openshell", - "provider", - "create", - "--name", - config.provider_name, - "--type", - config.provider_type, - "--runtime-credentials", - ]); + const providerCreate = await deps.run( + [ + "openshell", + "provider", + "create", + "--name", + config.provider_name, + "--type", + config.provider_type, + ...(isRefreshFlow ? ["--runtime-credentials"] : ["--credential", config.credential_key]), + ], + oboAccessToken === undefined + ? undefined + : { env: { [config.credential_key]: oboAccessToken } }, + ); if (providerCreate.exitCode !== 0) { throw new Error( - `Failed to create runtime identity provider '${config.provider_name}': ${deps.formatError(commandOutput(providerCreate))}`, + `Failed to create runtime identity provider '${config.provider_name}': ${deps.formatError(commandOutput(providerCreate), [oboAccessToken ?? ""])}`, ); } providerAcquired = true; deps.persistReceipt(receipt); - const refreshArgs = [ - "openshell", - "provider", - "refresh", - "configure", - config.provider_name, - "--credential-key", - config.credential_key, - "--strategy", - "oauth2-refresh-token", - "--material", - `client_id=${clientId}`, - "--secret-material-env", - `refresh_token=${config.refresh_token_env}`, - ]; - const refreshEnv: Record = { - [config.refresh_token_env]: refreshToken, - }; - if (config.client_secret_env && clientSecret) { - refreshArgs.push("--secret-material-env", `client_secret=${config.client_secret_env}`); - refreshEnv[config.client_secret_env] = clientSecret; - } - const refreshResult = await deps.run(refreshArgs, { env: refreshEnv }); - if (refreshResult.exitCode !== 0) { - throw new Error( - `Failed to configure runtime identity credential refresh: ${deps.formatError(commandOutput(refreshResult), [clientId, refreshToken, clientSecret ?? ""])}`, - ); - } + if (isRefreshFlow && config.refresh_token_env && refreshToken) { + const refreshArgs = [ + "openshell", + "provider", + "refresh", + "configure", + config.provider_name, + "--credential-key", + config.credential_key, + "--strategy", + "oauth2-refresh-token", + "--material", + `client_id=${clientId}`, + "--secret-material-env", + `refresh_token=${config.refresh_token_env}`, + ]; + const refreshEnv: Record = { + [config.refresh_token_env]: refreshToken, + }; + if (config.client_secret_env && clientSecret) { + refreshArgs.push("--secret-material-env", `client_secret=${config.client_secret_env}`); + refreshEnv[config.client_secret_env] = clientSecret; + } + const refreshResult = await deps.run(refreshArgs, { env: refreshEnv }); + if (refreshResult.exitCode !== 0) { + throw new Error( + `Failed to configure runtime identity credential refresh: ${deps.formatError(commandOutput(refreshResult), [clientId, refreshToken, clientSecret ?? ""])}`, + ); + } - const rotate = await deps.run([ - "openshell", - "provider", - "refresh", - "rotate", - config.provider_name, - "--credential-key", - config.credential_key, - ]); - if (rotate.exitCode !== 0) { - throw new Error( - `Failed to mint runtime identity credential: ${deps.formatError(commandOutput(rotate))}`, - ); + const rotate = await deps.run([ + "openshell", + "provider", + "refresh", + "rotate", + config.provider_name, + "--credential-key", + config.credential_key, + ]); + if (rotate.exitCode !== 0) { + throw new Error( + `Failed to mint runtime identity credential: ${deps.formatError(commandOutput(rotate))}`, + ); + } } } catch (error) { if (providerAcquired) { diff --git a/schemas/blueprint.schema.json b/schemas/blueprint.schema.json index 0c69f925597..c4e67eac1db 100644 --- a/schemas/blueprint.schema.json +++ b/schemas/blueprint.schema.json @@ -174,29 +174,45 @@ } }, "runtimeIdentity": { - "type": "object", - "required": [ - "profile_path", - "provider_type", - "provider_name", - "credential_key", - "client_id_env", - "refresh_token_env" - ], - "additionalProperties": false, - "properties": { - "profile_path": { "type": "string", "minLength": 1 }, - "provider_type": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$" }, - "provider_name": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,127}$" }, - "credential_key": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,255}$" }, - "client_id_env": { "$ref": "#/$defs/runtimeIdentityClientIdEnvName" }, - "refresh_token_env": { - "$ref": "#/$defs/runtimeIdentitySecretEnvName" + "oneOf": [ + { + "type": "object", + "required": ["profile_path", "provider_type", "provider_name", "credential_key", "client_id_env", "refresh_token_env"], + "additionalProperties": false, + "properties": { + "profile_path": { "type": "string", "minLength": 1 }, + "provider_type": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$" }, + "provider_name": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,127}$" }, + "credential_key": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,255}$" }, + "client_id_env": { "$ref": "#/$defs/runtimeIdentityClientIdEnvName" }, + "refresh_token_env": { "$ref": "#/$defs/runtimeIdentitySecretEnvName" }, + "client_secret_env": { "$ref": "#/$defs/runtimeIdentitySecretEnvName" } + } }, - "client_secret_env": { - "$ref": "#/$defs/runtimeIdentitySecretEnvName" + { + "type": "object", + "required": ["profile_path", "provider_type", "provider_name", "credential_key", "client_id_env", "subject_token_env", "client_secret_env", "token_url", "audience", "scopes"], + "additionalProperties": false, + "properties": { + "profile_path": { "type": "string", "minLength": 1 }, + "provider_type": { "type": "string", "pattern": "^[a-z][a-z0-9._-]{0,63}$" }, + "provider_name": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,127}$" }, + "credential_key": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,255}$" }, + "client_id_env": { "$ref": "#/$defs/runtimeIdentityClientIdEnvName" }, + "subject_token_env": { "$ref": "#/$defs/runtimeIdentitySecretEnvName" }, + "client_secret_env": { "$ref": "#/$defs/runtimeIdentitySecretEnvName" }, + "token_url": { "type": "string", "minLength": 1, "maxLength": 2048 }, + "audience": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "scopes": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9:._/-]+$" } + } + } } - } + ] }, "runtimeIdentityEnvName": { "type": "string", diff --git a/test/blueprint-runtime-identity-lifecycle.test.ts b/test/blueprint-runtime-identity-lifecycle.test.ts index 0212fd50eb9..3e2d5b3b899 100644 --- a/test/blueprint-runtime-identity-lifecycle.test.ts +++ b/test/blueprint-runtime-identity-lifecycle.test.ts @@ -21,6 +21,7 @@ import { interface FakeOpenShellCall { args: string[]; hasClientSecret: boolean; + hasOboAccessToken: boolean; hasRefreshToken: boolean; } @@ -49,6 +50,19 @@ const CONFIG = { client_secret_env: "OKTA_CLIENT_SECRET", } as const; +const OBO_CONFIG = { + profile_path: "provider-profiles/okta-obo-v1.yaml", + provider_type: "okta-obo-v1", + provider_name: "e2e-okta-obo", + credential_key: "OKTA_OBO_ACCESS_TOKEN", + client_id_env: "OKTA_CLIENT_ID", + client_secret_env: "OKTA_CLIENT_SECRET", + subject_token_env: "OKTA_SUBJECT_TOKEN", + token_url: "https://example.okta.com/oauth2/default/v1/token", + audience: "api://orders", + scopes: ["orders.read"], +} as const; + const INITIAL_STATE: FakeOpenShellState = { attachments: {}, calls: [], @@ -72,6 +86,7 @@ if (args[0] === "__test-state") { state.calls.push({ args, hasClientSecret: Boolean(process.env.OKTA_CLIENT_SECRET), + hasOboAccessToken: Boolean(process.env.OKTA_OBO_ACCESS_TOKEN), hasRefreshToken: Boolean(process.env.OKTA_REFRESH_TOKEN), }); @@ -114,9 +129,10 @@ if (args.join(" ").startsWith("provider profile import --file ")) { if (args[0] === "provider" && args[1] === "create") { const name = args[args.indexOf("--name") + 1]; const type = args[args.indexOf("--type") + 1]; + const credentialFlag = args.indexOf("--credential"); state.providers[name] = { configured: false, - credentialKey: "OKTA_ACCESS_TOKEN", + credentialKey: credentialFlag === -1 ? "OKTA_ACCESS_TOKEN" : args[credentialFlag + 1], rotated: false, type, }; @@ -271,4 +287,80 @@ describe("blueprint runtime identity lifecycle integration", () => { expect(state.providers).toEqual({}); expect(state.profiles).toEqual(["okta-runtime-v1"]); }); + + it("passes an exchanged OBO token only to provider creation through the command boundary", async () => { + const profileDir = path.join(root, "provider-profiles"); + const profilePath = path.join(profileDir, "okta-obo-v1.yaml"); + const statePath = path.join(root, "openshell-state.json"); + const fakeOpenShell = path.join(root, "openshell"); + mkdirSync(profileDir); + copyFileSync( + path.resolve("nemoclaw-blueprint/provider-profiles/okta-obo-v1.yaml"), + profilePath, + ); + writeFileSync(statePath, JSON.stringify(INITIAL_STATE, null, 2)); + writeFakeOpenShell(fakeOpenShell, statePath); + + const environment = { + OKTA_CLIENT_ID: "integration-client-id", + OKTA_CLIENT_SECRET: "integration-client-secret", + OKTA_SUBJECT_TOKEN: "integration-subject-token", + }; + const run = async ( + args: string[], + options?: RuntimeIdentityCommandOptions, + ): Promise => { + const result = await execa(fakeOpenShell, args.slice(1), { + env: { PATH: process.env.PATH ?? "", ...(options?.env ?? {}) }, + extendEnv: false, + reject: false, + }); + return { exitCode: result.exitCode ?? 1, stdout: result.stdout, stderr: result.stderr }; + }; + const deps: RuntimeIdentityDeps = { + blueprintPath: root, + env: environment, + formatError: (output, secrets = []) => + secrets.reduce((redacted, secret) => redacted.replaceAll(secret, ""), output), + persistReceipt: () => undefined, + run, + exchangeToken: async () => "integration-delegated-token", + validateEndpointUrl: async () => ({ dnsResolved: false }), + }; + + const receipt = await prepareRuntimeIdentity(OBO_CONFIG, deps); + const state = await readState(fakeOpenShell); + expect(receipt).toMatchObject({ + provider_type: "okta-obo-v1", + credential_key: "OKTA_OBO_ACCESS_TOKEN", + provider_created: true, + }); + expect(state.providers["e2e-okta-obo"]).toMatchObject({ + configured: false, + credentialKey: "OKTA_OBO_ACCESS_TOKEN", + type: "okta-obo-v1", + }); + expect( + state.calls.filter(({ hasOboAccessToken }) => hasOboAccessToken).map(({ args }) => args), + ).toEqual([ + [ + "provider", + "create", + "--name", + "e2e-okta-obo", + "--type", + "okta-obo-v1", + "--credential", + "OKTA_OBO_ACCESS_TOKEN", + ], + ]); + expect( + state.calls + .filter(({ args }) => !(args[0] === "provider" && args[1] === "create")) + .every(({ hasClientSecret, hasOboAccessToken }) => !hasClientSecret && !hasOboAccessToken), + ).toBe(true); + expect(JSON.stringify(state.calls)).not.toContain("integration-delegated-token"); + expect(JSON.stringify(state.calls)).not.toContain(environment.OKTA_CLIENT_SECRET); + expect(JSON.stringify(state.calls)).not.toContain(environment.OKTA_SUBJECT_TOKEN); + }); }); diff --git a/test/blueprint-runtime-identity-schema.test.ts b/test/blueprint-runtime-identity-schema.test.ts index 5b74696d1e7..2565adf3ac4 100644 --- a/test/blueprint-runtime-identity-schema.test.ts +++ b/test/blueprint-runtime-identity-schema.test.ts @@ -29,6 +29,19 @@ const runtimeIdentity = { client_secret_env: "OKTA_CLIENT_SECRET", }; +const oboRuntimeIdentity = { + profile_path: "provider-profiles/okta-obo-v1.yaml", + provider_type: "okta-obo-v1", + provider_name: "acme-okta-obo", + credential_key: "OKTA_OBO_ACCESS_TOKEN", + client_id_env: "OKTA_CLIENT_ID", + client_secret_env: "OKTA_CLIENT_SECRET", + subject_token_env: "OKTA_SUBJECT_TOKEN", + token_url: "https://example.okta.com/oauth2/default/v1/token", + audience: "api://orders", + scopes: ["orders.read"], +}; + function blueprintWithIdentity(identity: object): object { return { ...baseBlueprint, @@ -44,6 +57,14 @@ describe("blueprint runtime identity schema", () => { expect(isRuntimeIdentityConfig(runtimeIdentity)).toBe(true); }); + it("accepts the host-managed Okta OBO contract", () => { + expect( + validate(blueprintWithIdentity(oboRuntimeIdentity)), + JSON.stringify(validate.errors), + ).toBe(true); + expect(isRuntimeIdentityConfig(oboRuntimeIdentity)).toBe(true); + }); + it("rejects an identity-provider discriminator", () => { expect(validate(blueprintWithIdentity({ okta: runtimeIdentity }))).toBe(false); }); @@ -82,4 +103,15 @@ describe("blueprint runtime identity schema", () => { expect(validate(blueprintWithIdentity(unsupportedIdentity))).toBe(false); expect(isRuntimeIdentityConfig(unsupportedIdentity)).toBe(false); }); + + it("rejects a token-exchange config that mixes the refresh flow", () => { + expect( + validate( + blueprintWithIdentity({ + ...oboRuntimeIdentity, + refresh_token_env: "OKTA_REFRESH_TOKEN", + }), + ), + ).toBe(false); + }); }); diff --git a/test/docs-refactor-ownership.test.ts b/test/docs-refactor-ownership.test.ts index 5cf3a0963d2..b00964f905a 100644 --- a/test/docs-refactor-ownership.test.ts +++ b/test/docs-refactor-ownership.test.ts @@ -33,11 +33,13 @@ describe("focused documentation ownership", () => { expect(architecture).toContain( "[Configure Experimental Runtime Identity](configure-runtime-identity)", ); - expect(architecture).toContain("Okta and Microsoft Entra reference profiles"); + expect(architecture).toContain("Okta refresh, Okta OBO, Microsoft Entra"); expect(architecture).not.toContain("identity:\n profile_path:"); expect(runtimeIdentity).toContain("identity:\n profile_path:"); expect(runtimeIdentity).toContain("## Roll Back Runtime Identity"); - expect(runtimeIdentity).toContain("including its Okta and Microsoft Entra profiles"); + expect(runtimeIdentity).toContain( + "including its Okta refresh, Okta on-behalf-of, and Microsoft Entra profiles", + ); expect(runtimeIdentity).toContain("unset OKTA_REFRESH_TOKEN OKTA_CLIENT_SECRET"); expect(runtimeIdentity).toContain( "OpenShell retains the refresh material in the gateway credential store until rollback or provider deletion", diff --git a/test/e2e/live/inference-routing.test.ts b/test/e2e/live/inference-routing.test.ts index ee958ed60f5..ae968d10389 100644 --- a/test/e2e/live/inference-routing.test.ts +++ b/test/e2e/live/inference-routing.test.ts @@ -490,6 +490,7 @@ async function runRuntimeIdentityE2EScenario( const runtimeIdentityProfilePolicy = { providerType, clientIdEnvironmentName: scenario.clientIdEnvironmentName, + flow: "oauth2-refresh-token" as const, dnsResolution: "identity-platform-controlled", tokenIssuer: { trustedHostnames: [endpoint.hostname], @@ -498,6 +499,7 @@ async function runRuntimeIdentityE2EScenario( credentialDelivery: { method: "GET", path: scenario.reviewedResourcePath, + hostPolicy: "reviewed" as const, trustedHostnames: [endpoint.hostname], trustedHostSuffixes: [], }, From 1b65042171128764b0f36466dcf358abb96b14ad Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Mon, 3 Aug 2026 11:14:36 -0700 Subject: [PATCH 02/10] fix(blueprint): support current OpenShell sandbox lifecycle Signed-off-by: Alex Fournier --- .../src/blueprint/runner-identity.test.ts | 22 +++++++++---------- .../blueprint/runner-name-validation.test.ts | 2 +- nemoclaw/src/blueprint/runner.test.ts | 4 +++- nemoclaw/src/blueprint/runner.ts | 10 ++++++--- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/nemoclaw/src/blueprint/runner-identity.test.ts b/nemoclaw/src/blueprint/runner-identity.test.ts index 60c0d573b1f..22b5f87a33b 100644 --- a/nemoclaw/src/blueprint/runner-identity.test.ts +++ b/nemoclaw/src/blueprint/runner-identity.test.ts @@ -431,7 +431,7 @@ describe("blueprint identity wrapper", () => { ], ], [ - "sandbox create --from openclaw --name test-sandbox --forward 18789", + "sandbox create --from openclaw --name test-sandbox --forward 18789 --no-tty -- /bin/true", [{ exitCode: 1, stdout: "", stderr: "sandbox already exists" }], ], ]); @@ -744,7 +744,7 @@ describe("blueprint identity wrapper", () => { ); expect(mockExeca).toHaveBeenCalledWith( "openshell", - ["sandbox", "remove", "test-sandbox"], + ["sandbox", "delete", "test-sandbox"], expect.objectContaining({ reject: false }), ); const planEntry = [...store.entries()].find(([path]) => path.endsWith("/plan.json"))?.[1]; @@ -795,7 +795,7 @@ describe("blueprint identity wrapper", () => { const commands = mockExeca.mock.calls.map(([, args]) => (args ?? []).join(" ")); expect(commands).toContain("sandbox provider detach test-sandbox acme-okta-runtime"); expect(commands).toContain("provider delete acme-okta-runtime"); - expect(commands).toContain("sandbox remove test-sandbox"); + expect(commands).toContain("sandbox delete test-sandbox"); expect(commands).toContain("provider delete test-provider"); const planEntry = [...store.entries()].find(([path]) => path.endsWith("/plan.json"))?.[1]; expect(JSON.parse(planEntry!.content!)).toMatchObject({ @@ -837,7 +837,7 @@ describe("blueprint identity wrapper", () => { expect(mockExeca).toHaveBeenCalledWith( "openshell", - ["sandbox", "remove", "test-sandbox"], + ["sandbox", "delete", "test-sandbox"], expect.objectContaining({ reject: false }), ); }); @@ -872,7 +872,7 @@ describe("blueprint identity wrapper", () => { const applyCommands = mockExeca.mock.calls.map(([, args]) => (args ?? []).join(" ")); expect(applyCommands).not.toContain( - "sandbox create --from openclaw --name test-sandbox --forward 18789", + "sandbox create --from openclaw --name test-sandbox --forward 18789 --no-tty -- /bin/true", ); expect(applyCommands).toContain("provider get test-provider"); expect(applyCommands).toContain("inference get"); @@ -902,7 +902,7 @@ describe("blueprint identity wrapper", () => { const rollbackCommands = mockExeca.mock.calls.map(([, args]) => (args ?? []).join(" ")); expect(rollbackCommands).not.toContain("sandbox stop test-sandbox"); - expect(rollbackCommands).not.toContain("sandbox remove test-sandbox"); + expect(rollbackCommands).not.toContain("sandbox delete test-sandbox"); expect(rollbackCommands).toContain("sandbox provider detach test-sandbox acme-okta-runtime"); expect(rollbackCommands).toContain("provider delete acme-okta-runtime"); }); @@ -919,7 +919,7 @@ describe("blueprint identity wrapper", () => { const rollbackCommands = mockExeca.mock.calls.map(([, args]) => (args ?? []).join(" ")); expect(rollbackCommands).not.toContain("sandbox stop pre-existing-sandbox"); - expect(rollbackCommands).not.toContain("sandbox remove pre-existing-sandbox"); + expect(rollbackCommands).not.toContain("sandbox delete pre-existing-sandbox"); expect(store.get(`${stateDir}/rolled_back`)?.content).toBeDefined(); }); @@ -977,7 +977,7 @@ describe("blueprint identity wrapper", () => { await actionRollback(JSON.parse(planEntry!.content!).run_id); const rollbackCommands = mockExeca.mock.calls.map(([, args]) => (args ?? []).join(" ")); expect(rollbackCommands).toContain("provider delete test-provider"); - expect(rollbackCommands).not.toContain("sandbox remove test-sandbox"); + expect(rollbackCommands).not.toContain("sandbox delete test-sandbox"); }); it("keeps an owned sandbox receipt retryable when removal fails", async () => { @@ -991,11 +991,11 @@ describe("blueprint identity wrapper", () => { }), }); responseQueue([ - ["sandbox remove owned-sandbox", [{ exitCode: 1, stdout: "", stderr: "remove denied" }]], + ["sandbox delete owned-sandbox", [{ exitCode: 1, stdout: "", stderr: "delete denied" }]], ]); await expect(actionRollback("failed-sandbox-removal")).rejects.toThrow( - /Failed to remove owned sandbox 'owned-sandbox': remove denied/, + /Failed to remove owned sandbox 'owned-sandbox': delete denied/, ); expect(store.get(`${stateDir}/rolled_back`)).toBeUndefined(); }); @@ -1194,7 +1194,7 @@ describe("blueprint identity wrapper", () => { ); expect(mockExeca).toHaveBeenCalledWith( "openshell", - ["sandbox", "remove", "test-sandbox"], + ["sandbox", "delete", "test-sandbox"], expect.objectContaining({ reject: false }), ); expect(mockExeca).toHaveBeenCalledWith( diff --git a/nemoclaw/src/blueprint/runner-name-validation.test.ts b/nemoclaw/src/blueprint/runner-name-validation.test.ts index 9afef5b15cf..049a1eaaf40 100644 --- a/nemoclaw/src/blueprint/runner-name-validation.test.ts +++ b/nemoclaw/src/blueprint/runner-name-validation.test.ts @@ -215,7 +215,7 @@ describe("blueprint name validation (fail-closed integration)", () => { it("rollback rejects a plan whose sandbox_name is not an RFC 1035 label", async () => { const runDir = `${RUNS_DIR}/nc-run-1`; addDir(runDir); - // "--rm" would be consumed as a flag by `openshell sandbox stop/remove`. + // "--rm" would be consumed as a flag by `openshell sandbox stop/delete`. addFile(`${runDir}/plan.json`, JSON.stringify({ sandbox_name: "--rm" })); await expect(actionRollback("nc-run-1")).rejects.toThrow(/Invalid sandbox name/); diff --git a/nemoclaw/src/blueprint/runner.test.ts b/nemoclaw/src/blueprint/runner.test.ts index cbe17757f1e..e389328f646 100644 --- a/nemoclaw/src/blueprint/runner.test.ts +++ b/nemoclaw/src/blueprint/runner.test.ts @@ -584,7 +584,9 @@ describe("runner", () => { expect(mockExeca).toHaveBeenCalledWith( "openshell", - ["sandbox", "create", "--from", "openclaw", "--name", "test-sandbox", "--forward", "18789"], + "sandbox create --from openclaw --name test-sandbox --forward 18789 --no-tty -- /bin/true".split( + " ", + ), expect.objectContaining({ reject: false }), ); }); diff --git a/nemoclaw/src/blueprint/runner.ts b/nemoclaw/src/blueprint/runner.ts index 5e3a9a0c119..340cbc02f8c 100644 --- a/nemoclaw/src/blueprint/runner.ts +++ b/nemoclaw/src/blueprint/runner.ts @@ -389,7 +389,7 @@ function readRollbackSandboxName(value: RollbackPlanSource | null): string { } // The persisted plan is untrusted input at this boundary too: validate before - // the name reaches `openshell sandbox stop/remove`, mirroring the apply path. + // the name reaches `openshell sandbox stop/delete`, mirroring the apply path. return assertValidName(value.sandbox_name, "sandbox name"); } @@ -1071,6 +1071,10 @@ export async function actionApply( for (const port of forwardPorts) { createArgs.push("--forward", String(port)); } + // The OpenShell CLI defaults to an interactive shell when no command is + // provided. The blueprint runner is headless, so use a no-op command and + // leave the created sandbox running after that command exits. + createArgs.push("--no-tty", "--", "/bin/true"); const createResult = await runCmd(createArgs, { reject: false }); sandboxCreatedByApply = createResult.exitCode === 0; @@ -1268,7 +1272,7 @@ export async function actionApply( } if (sandboxCreatedByApply) { await runCmd(["openshell", "sandbox", "stop", sandboxName], { reject: false }); - const remove = await runCmd(["openshell", "sandbox", "remove", sandboxName], { + const remove = await runCmd(["openshell", "sandbox", "delete", sandboxName], { reject: false, }); if (remove.exitCode === 0 || MISSING_SANDBOX_PATTERN.test(remove.stderr)) { @@ -1409,7 +1413,7 @@ export async function actionRollback(rid: string): Promise { const stop = await runCmd(["openshell", "sandbox", "stop", sandboxName], { reject: false }); progress(60, `Removing sandbox ${sandboxName}`); - const remove = await runCmd(["openshell", "sandbox", "remove", sandboxName], { + const remove = await runCmd(["openshell", "sandbox", "delete", sandboxName], { reject: false, }); if (remove.exitCode !== 0 && !MISSING_SANDBOX_PATTERN.test(remove.stderr)) { From 6d6913ea37b25d46ce9d01f2e7a31138afd457cb Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Mon, 3 Aug 2026 11:24:42 -0700 Subject: [PATCH 03/10] test(identity): cover OBO failure paths Signed-off-by: Alex Fournier --- .../src/blueprint/runner-identity.test.ts | 7 +++ .../src/blueprint/runtime-identity.test.ts | 46 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/nemoclaw/src/blueprint/runner-identity.test.ts b/nemoclaw/src/blueprint/runner-identity.test.ts index 22b5f87a33b..b446d6c6887 100644 --- a/nemoclaw/src/blueprint/runner-identity.test.ts +++ b/nemoclaw/src/blueprint/runner-identity.test.ts @@ -1066,6 +1066,13 @@ describe("blueprint identity wrapper", () => { { exitCode: 0, stdout: "", stderr: "" }, ], ], + [ + "sandbox delete test-sandbox", + [ + { exitCode: 1, stdout: "", stderr: "sandbox delete denied" }, + { exitCode: 0, stdout: "", stderr: "" }, + ], + ], [ "inference set --provider test-provider --model test-model", [{ exitCode: 1, stdout: "", stderr: "route failed" }], diff --git a/nemoclaw/src/blueprint/runtime-identity.test.ts b/nemoclaw/src/blueprint/runtime-identity.test.ts index 495a6588049..04e915a1dce 100644 --- a/nemoclaw/src/blueprint/runtime-identity.test.ts +++ b/nemoclaw/src/blueprint/runtime-identity.test.ts @@ -28,6 +28,7 @@ import { type RuntimeIdentityCommandResult, type RuntimeIdentityConfig, type RuntimeIdentityDeps, + type RuntimeIdentityFetch, type RuntimeIdentityReceipt, removeRuntimeIdentity, resolveRuntimeIdentityProfilePath, @@ -514,6 +515,51 @@ describe("runtime identity contract", () => { ).rejects.toThrow("Okta token exchange failed with HTTP 400"); }); + it.each<[string, RuntimeIdentityFetch, RegExp]>([ + ["request failure", async () => Promise.reject(new Error("network details")), /request failed/], + [ + "invalid JSON", + async () => new Response("not-json", { status: 200 }), + /response was not valid JSON/, + ], + [ + "unsafe access token", + async () => new Response(JSON.stringify({ access_token: "unsafe\ntoken" }), { status: 200 }), + /did not contain a safe access token/, + ], + ])("rejects an Okta exchange %s without disclosing response details", async (_label, fetchImpl, error) => { + await expect( + exchangeOktaOboToken( + { + tokenUrl: "https://example.okta.com/oauth2/default/v1/token", + clientId: "client-id", + clientSecret: "client-secret", + subjectToken: "user-subject-token", + audience: "api://orders", + scopes: ["orders.read"], + }, + fetchImpl, + ), + ).rejects.toThrow(error); + }); + + it.each([ + [ + "inspection failure", + { exitCode: 1, stdout: "", stderr: "private gateway detail" }, + /Failed to inspect OpenShell provider-policy prerequisite/, + ], + [ + "malformed response", + { exitCode: 0, stdout: "{", stderr: "" }, + /requires OpenShell global setting 'providers_v2_enabled=true'/, + ], + ])("fails before identity mutation after a settings %s", async (_label, response, error) => { + responses.set("settings get --global --json", [response]); + await expect(prepareRuntimeIdentity(config, deps)).rejects.toThrow(error); + expect(calls.map(({ args }) => commandKey(args))).toEqual(["settings get --global --json"]); + }); + it("fails before identity mutation when provider-derived policy is disabled", async () => { responses.set("settings get --global --json", [ { From 82fd2cae1bc8cfd1715a81486001eb19692f5312 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 4 Aug 2026 08:35:39 -0700 Subject: [PATCH 04/10] test(identity): cover live Okta OBO boundary Signed-off-by: Alex Fournier --- test/e2e/live/inference-routing.test.ts | 12 + .../e2e/live/runtime-identity-oauth-server.ts | 85 ++- .../e2e/live/runtime-identity-obo-scenario.ts | 482 ++++++++++++++++++ 3 files changed, 575 insertions(+), 4 deletions(-) create mode 100644 test/e2e/live/runtime-identity-obo-scenario.ts diff --git a/test/e2e/live/inference-routing.test.ts b/test/e2e/live/inference-routing.test.ts index ae968d10389..686d48a3d1c 100644 --- a/test/e2e/live/inference-routing.test.ts +++ b/test/e2e/live/inference-routing.test.ts @@ -38,6 +38,10 @@ import { } from "./inference-routing-helpers.ts"; import { startPublicMcpHttpsTunnel } from "./mcp-bridge-servers.ts"; import { startRuntimeIdentityOAuthServer } from "./runtime-identity-oauth-server.ts"; +import { + RUNTIME_IDENTITY_OBO_E2E_OPTIONS, + runRuntimeIdentityOboE2EScenario, +} from "./runtime-identity-obo-scenario.ts"; // This is the PR-required inference-routing lane. Credential-backed provider // smokes live in inference-routing-provider-smoke.test.ts and are never selected @@ -991,6 +995,14 @@ test.for(RUNTIME_IDENTITY_E2E_SCENARIOS)( }, ); +test( + "TC-INF-14 Okta OBO creates a ready sandbox and injects only an admitted delegated bearer", + { + ...RUNTIME_IDENTITY_OBO_E2E_OPTIONS, + }, + runRuntimeIdentityOboE2EScenario, +); + test("TC-INF-09 Deep Agents Code uses a local compatible endpoint through inference.local (#5744)", { timeout: 20 * 60_000, meta: { diff --git a/test/e2e/live/runtime-identity-oauth-server.ts b/test/e2e/live/runtime-identity-oauth-server.ts index 2d4edbf5cf9..b8c104d186a 100644 --- a/test/e2e/live/runtime-identity-oauth-server.ts +++ b/test/e2e/live/runtime-identity-oauth-server.ts @@ -32,8 +32,22 @@ export interface RuntimeIdentityResourceRequest { readonly accessTokenVersion: number | null; } +export interface RuntimeIdentityTokenExchangeRequest { + readonly method: string; + readonly path: string; + readonly grantTypeOk: boolean; + readonly subjectTokenOk: boolean; + readonly subjectTokenTypeOk: boolean; + readonly requestedTokenTypeOk: boolean; + readonly audienceOk: boolean; + readonly scopeOk: boolean; + readonly clientAuthOk: boolean; + readonly issued: boolean; +} + export interface RuntimeIdentityOAuthServer extends StartedHttpServer { tokenRequests(): readonly RuntimeIdentityTokenRequest[]; + tokenExchangeRequests(): readonly RuntimeIdentityTokenExchangeRequest[]; resourceRequests(): readonly RuntimeIdentityResourceRequest[]; secretValues(): readonly string[]; } @@ -111,19 +125,26 @@ function classifyBearer( export async function startRuntimeIdentityOAuthServer(options: { clientId: string; clientSecret: string; - initialRefreshToken: string; + initialRefreshToken?: string; resourcePath?: string; tokenPath?: string; + tokenExchange?: { + subjectToken: string; + audience: string; + scope: string; + accessToken: string; + }; }): Promise { const tls = generateEphemeralTlsMaterial(); const tokenRequests: RuntimeIdentityTokenRequest[] = []; + const tokenExchangeRequests: RuntimeIdentityTokenExchangeRequest[] = []; const resourceRequests: RuntimeIdentityResourceRequest[] = []; const accessTokens = [ - "e2e-runtime-identity-access-token-v1", + options.tokenExchange?.accessToken ?? "e2e-runtime-identity-access-token-v1", "e2e-runtime-identity-access-token-v2", ]; const refreshTokens = [ - options.initialRefreshToken, + options.initialRefreshToken ?? "e2e-runtime-identity-unused-refresh-token", "e2e-runtime-identity-rotated-refresh-token-v2", "e2e-runtime-identity-rotated-refresh-token-v3", ]; @@ -146,6 +167,53 @@ export async function startRuntimeIdentityOAuthServer(options: { if (req.method === "POST" && requestPath === tokenPath) { const body = new URLSearchParams(await readRequestBody(req)); + if (body.get("grant_type") === "urn:ietf:params:oauth:grant-type:token-exchange") { + const tokenExchange = options.tokenExchange; + const grantTypeOk = tokenExchange !== undefined; + const subjectTokenOk = body.get("subject_token") === tokenExchange?.subjectToken; + const subjectTokenTypeOk = + body.get("subject_token_type") === "urn:ietf:params:oauth:token-type:access_token"; + const requestedTokenTypeOk = + body.get("requested_token_type") === "urn:ietf:params:oauth:token-type:access_token"; + const audienceOk = body.get("audience") === tokenExchange?.audience; + const scopeOk = body.get("scope") === tokenExchange?.scope; + const clientAuthOk = + req.headers.authorization === + `Basic ${Buffer.from(`${options.clientId}:${options.clientSecret}`).toString("base64")}`; + const issued = + grantTypeOk && + subjectTokenOk && + subjectTokenTypeOk && + requestedTokenTypeOk && + audienceOk && + scopeOk && + clientAuthOk && + issueCount === 0; + tokenExchangeRequests.push({ + method: req.method, + path: requestPath, + grantTypeOk, + subjectTokenOk, + subjectTokenTypeOk, + requestedTokenTypeOk, + audienceOk, + scopeOk, + clientAuthOk, + issued, + }); + if (!issued) { + writeJsonResponse(res, 400, { error: "invalid_grant" }); + return; + } + currentAccessToken = accessTokens[0]; + issueCount = 1; + writeJsonResponse(res, 200, { + access_token: currentAccessToken, + token_type: "Bearer", + expires_in: 600, + }); + return; + } const grantTypeOk = body.get("grant_type") === "refresh_token"; const clientIdOk = body.get("client_id") === options.clientId; const refreshTokenOk = body.get("refresh_token") === currentRefreshToken; @@ -219,8 +287,17 @@ export async function startRuntimeIdentityOAuthServer(options: { return { port: requireTcpPort(server), tokenRequests: () => tokenRequests, + tokenExchangeRequests: () => tokenExchangeRequests, resourceRequests: () => resourceRequests, - secretValues: () => [options.clientId, options.clientSecret, ...accessTokens, ...refreshTokens], + secretValues: () => [ + options.clientId, + options.clientSecret, + ...(options.tokenExchange + ? [options.tokenExchange.subjectToken, options.tokenExchange.accessToken] + : []), + ...accessTokens, + ...refreshTokens, + ], close: async () => { await closeServer(server); fs.rmSync(tls.dir, { recursive: true, force: true }); diff --git a/test/e2e/live/runtime-identity-obo-scenario.ts b/test/e2e/live/runtime-identity-obo-scenario.ts new file mode 100644 index 00000000000..4d22f0e2253 --- /dev/null +++ b/test/e2e/live/runtime-identity-obo-scenario.ts @@ -0,0 +1,482 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; +import { resultText } from "../fixtures/clients/command.ts"; +import { type E2ETargetFixtures, expect } from "../fixtures/e2e-test.ts"; +import { startFakeOpenAiCompatibleServer } from "../fixtures/fake-openai-compatible.ts"; +import { REPO_ROOT } from "../fixtures/paths.ts"; +import { resolveVerifiedCloudflaredBinary } from "./cloudflared-prerequisite.ts"; +import { + cleanupSandbox, + expectOnboardSuccess, + inferenceSandboxName, + onboardSandbox, + requireLivePrerequisites, + runRawCommand, +} from "./inference-routing-helpers.ts"; +import { startPublicMcpHttpsTunnel } from "./mcp-bridge-servers.ts"; +import { startRuntimeIdentityOAuthServer } from "./runtime-identity-oauth-server.ts"; + +type RuntimeIdentityOboContext = Pick< + E2ETargetFixtures, + "artifacts" | "cleanup" | "host" | "progress" | "sandbox" +> & { + skip: (note?: string) => never; +}; + +export const RUNTIME_IDENTITY_OBO_E2E_OPTIONS = { + timeout: 20 * 60_000, + meta: { + e2ePhases: [ + "confirm live OBO prerequisites", + "establish the reusable inference route", + "start the public OBO issuer and protected resource", + "apply OBO identity and create the sandbox through OpenShell", + "prove admitted bearer delivery and denied-request isolation", + "rollback the owned OBO sandbox and provider", + ], + }, +} as const; + +export async function runRuntimeIdentityOboE2EScenario( + context: RuntimeIdentityOboContext, +): Promise { + const { artifacts, cleanup, host, progress, sandbox, skip } = context; + const artifactPrefix = "tc-inf-14"; + await requireLivePrerequisites(host, skip); + + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-identity-obo-e2e-")); + const workdir = path.join(root, "blueprint"); + const profileDir = path.join(workdir, "provider-profiles"); + fs.mkdirSync(profileDir, { recursive: true }); + cleanup.add(`remove OBO runtime identity E2E temp root ${root}`, () => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + const model = "nemoclaw-e2e-runtime-identity-obo"; + const inferenceKey = "sk-runtime-identity-obo-TEST-NOT-A-REAL-VALUE"; + const prerequisiteSandboxName = inferenceSandboxName("e2e-tc-inf-14-prerequisite"); + const sandboxName = inferenceSandboxName("e2e-tc-inf-14"); + const providerType = "okta-obo-v1"; + const providerName = `e2e-okta-obo-v1-${String(process.pid)}`; + const credentialKey = "OKTA_OBO_ACCESS_TOKEN"; + const clientId = "e2e-okta-obo-client-id"; + const clientSecret = "e2e-okta-obo-client-secret"; + const subjectToken = "e2e-okta-obo-subject-token"; + const delegatedToken = "e2e-okta-obo-delegated-token"; + const audience = "api://nemoclaw-e2e"; + const scope = "resource.read"; + const tokenPath = "/oauth/token"; + const resourcePath = "/resource"; + const openshellEnv = { + ...buildAvailabilityProbeEnv(), + OPENSHELL_GATEWAY: process.env.OPENSHELL_GATEWAY ?? "nemoclaw", + }; + + cleanup.add(`remove OBO runtime identity sandbox residue for ${sandboxName}`, () => + cleanupSandbox(host, sandbox, sandboxName), + ); + cleanup.add(`remove OBO prerequisite sandbox residue for ${prerequisiteSandboxName}`, () => + cleanupSandbox(host, sandbox, prerequisiteSandboxName), + ); + await cleanupSandbox(host, sandbox, sandboxName); + await cleanupSandbox(host, sandbox, prerequisiteSandboxName); + + const inference = await startFakeOpenAiCompatibleServer({ + apiKey: inferenceKey, + chatContent: "PONG", + host: "0.0.0.0", + model, + port: 8000, + progress, + publicHost: "localhost", + requireAuth: true, + requireAuthModels: true, + }); + cleanup.add("close OBO runtime identity inference prerequisite", () => inference.close()); + + progress.phase("establish the reusable inference route"); + const onboard = await onboardSandbox( + artifacts, + prerequisiteSandboxName, + { + COMPATIBLE_API_KEY: inferenceKey, + NEMOCLAW_ENDPOINT_URL: inference.baseUrl, + NEMOCLAW_MODEL: model, + NEMOCLAW_PREFERRED_API: "openai-completions", + NEMOCLAW_PROVIDER: "custom", + }, + [inferenceKey], + `${artifactPrefix}-onboard-inference-prerequisite`, + progress, + 15 * 60_000, + ); + expectOnboardSuccess(onboard, "TC-INF-14 inference prerequisite onboard"); + + await sandbox.openshell(["provider", "delete", providerName], { + artifactName: `${artifactPrefix}-preclean-provider`, + env: openshellEnv, + timeoutMs: 30_000, + }); + await sandbox.openshell(["provider", "profile", "delete", providerType], { + artifactName: `${artifactPrefix}-preclean-profile`, + env: openshellEnv, + timeoutMs: 30_000, + }); + + const settingsBefore = await sandbox.openshell(["settings", "get", "--global", "--json"], { + artifactName: `${artifactPrefix}-provider-policy-setting-before`, + env: openshellEnv, + timeoutMs: 30_000, + }); + expect(settingsBefore.exitCode, resultText(settingsBefore)).toBe(0); + const settingsDocument = JSON.parse(settingsBefore.stdout) as { + settings?: Record; + }; + const restoreSettingArgs = new Map([ + ["", ["settings", "delete", "--global", "--key", "providers_v2_enabled", "--yes"]], + [ + "false", + ["settings", "set", "--global", "--key", "providers_v2_enabled", "--value", "false", "--yes"], + ], + [ + "true", + ["settings", "set", "--global", "--key", "providers_v2_enabled", "--value", "true", "--yes"], + ], + ]).get(settingsDocument.settings?.providers_v2_enabled ?? ""); + expect(restoreSettingArgs).toBeDefined(); + cleanup.add("restore OBO provider-derived policy setting", async () => { + const restored = await sandbox.openshell(restoreSettingArgs!, { + artifactName: `${artifactPrefix}-provider-policy-setting-restore`, + env: openshellEnv, + timeoutMs: 30_000, + }); + expect(restored.exitCode, resultText(restored)).toBe(0); + }); + const enableProviderPolicy = await sandbox.openshell( + ["settings", "set", "--global", "--key", "providers_v2_enabled", "--value", "true", "--yes"], + { + artifactName: `${artifactPrefix}-provider-policy-setting-enable`, + env: openshellEnv, + timeoutMs: 30_000, + }, + ); + expect(enableProviderPolicy.exitCode, resultText(enableProviderPolicy)).toBe(0); + + progress.phase("start the public OBO issuer and protected resource"); + const oauth = await startRuntimeIdentityOAuthServer({ + clientId, + clientSecret, + resourcePath, + tokenPath, + tokenExchange: { subjectToken, audience, scope, accessToken: delegatedToken }, + }); + cleanup.add("close OBO token-exchange fixture", async () => { + try { + await artifacts.writeJson( + `${artifactPrefix}-token-exchange-requests.json`, + oauth.tokenExchangeRequests(), + ); + await artifacts.writeJson( + `${artifactPrefix}-protected-resource-requests.json`, + oauth.resourceRequests(), + ); + } finally { + await oauth.close(); + } + }); + const cloudflaredBin = await resolveVerifiedCloudflaredBinary(cleanup, host); + const tunnel = await startPublicMcpHttpsTunnel({ + cloudflaredBin, + cleanup, + label: "runtime identity OBO", + progress, + readinessPath: resourcePath, + readinessStatus: 401, + server: oauth, + }); + const endpoint = new URL(tunnel.origin); + + fs.writeFileSync( + path.join(profileDir, `${providerType}.yaml`), + [ + `id: ${providerType}`, + "display_name: TC-INF-14 Okta OBO Runtime Identity Conformance", + "description: Deterministic host exchange and real OpenShell bearer-injection conformance profile", + "category: agent", + "credentials:", + ` - name: ${credentialKey}`, + " description: Short-lived conformance delegated token", + " env_vars:", + ` - ${credentialKey}`, + " required: true", + " auth_style: bearer", + " header_name: authorization", + "endpoints:", + ` - host: ${endpoint.hostname}`, + " port: 443", + " protocol: rest", + " enforcement: enforce", + " rules:", + ` - allow: { method: GET, path: "${resourcePath}" }`, + "binaries:", + " - /usr/local/bin/node", + " - /usr/bin/node", + " - /usr/local/bin/curl", + " - /usr/bin/curl", + "inference_capable: false", + "", + ].join("\n"), + { mode: 0o600 }, + ); + fs.writeFileSync( + path.join(workdir, "blueprint.yaml"), + [ + 'version: "1.0"', + "components:", + " sandbox:", + " image: openclaw", + ` name: ${sandboxName}`, + " inference:", + " profiles:", + " default:", + " provider_type: openai", + " provider_name: compatible-endpoint", + ` model: ${model}`, + " identity:", + ` profile_path: provider-profiles/${providerType}.yaml`, + ` provider_type: ${providerType}`, + ` provider_name: ${providerName}`, + ` credential_key: ${credentialKey}`, + " client_id_env: OKTA_CLIENT_ID", + " client_secret_env: OKTA_CLIENT_SECRET", + " subject_token_env: OKTA_SUBJECT_TOKEN", + ` token_url: ${tunnel.origin}${tokenPath}`, + ` audience: ${audience}`, + " scopes:", + ` - ${scope}`, + "", + ].join("\n"), + { mode: 0o600 }, + ); + + const runtimeIdentityProfilePolicy = { + providerType, + clientIdEnvironmentName: "OKTA_CLIENT_ID", + flow: "oauth2-token-exchange" as const, + dnsResolution: "identity-platform-controlled", + tokenIssuer: { + trustedHostnames: [endpoint.hostname], + trustedHostSuffixes: [], + }, + credentialDelivery: { + method: "GET", + path: resourcePath, + hostPolicy: "reviewed" as const, + trustedHostnames: [endpoint.hostname], + trustedHostSuffixes: [], + }, + trustedBinaries: [ + "/usr/local/bin/node", + "/usr/bin/node", + "/usr/local/bin/curl", + "/usr/bin/curl", + ], + }; + const redactionValues = [...oauth.secretValues(), inferenceKey]; + const runnerPath = path.join(REPO_ROOT, "nemoclaw/src/blueprint/runner.ts"); + const tsxPath = path.join(REPO_ROOT, "node_modules/tsx/dist/cli.mjs"); + const runnerEnv = { + ...openshellEnv, + OKTA_CLIENT_ID: clientId, + OKTA_CLIENT_SECRET: clientSecret, + OKTA_SUBJECT_TOKEN: subjectToken, + }; + + await artifacts.target.declare({ + id: "okta-obo-runtime-identity-real-boundary", + issue: 6871, + contract: [ + "the host exchanges the subject token without exposing OAuth material to the sandbox", + "the direct blueprint runner creates a ready sandbox with the headless no-op command", + "the sandbox receives only an opaque provider placeholder", + "OpenShell substitutes the delegated bearer only for the admitted request", + "an unreviewed method is denied before bearer delivery", + "rollback removes the owned sandbox and provider", + ], + openshellBoundary: "real gateway, provider creation, attachment, sandbox exec, L7 injection", + oauthBoundary: "deterministic RFC 8693 fixture over public DNS and trusted TLS", + }); + + progress.phase("apply OBO identity and create the sandbox through OpenShell"); + const apply = await runRawCommand( + process.execPath, + [ + tsxPath, + "--input-type=module", + "--eval", + `const { main } = await import(${JSON.stringify(runnerPath)}); await main(["apply"], { runtimeIdentityProfilePolicy: ${JSON.stringify(runtimeIdentityProfilePolicy)} });`, + ], + { + artifactName: `${artifactPrefix}-runtime-identity-apply`, + artifacts, + cwd: workdir, + env: runnerEnv, + progress, + redactionValues, + timeoutMs: 5 * 60_000, + }, + ); + const applyText = resultText(apply); + expect(apply.exitCode, applyText).toBe(0); + expect(applyText).toContain(`Sandbox '${sandboxName}' is ready.`); + expect(applyText).not.toContain("already exists, reusing"); + for (const secret of redactionValues) expect(applyText).not.toContain(secret); + expect(oauth.tokenExchangeRequests()).toEqual([ + { + method: "POST", + path: tokenPath, + grantTypeOk: true, + subjectTokenOk: true, + subjectTokenTypeOk: true, + requestedTokenTypeOk: true, + audienceOk: true, + scopeOk: true, + clientAuthOk: true, + issued: true, + }, + ]); + + const runId = /^RUN_ID:(\S+)$/m.exec(apply.stdout)?.[1]; + expect(runId).toMatch(/^nc-[A-Za-z0-9-]+$/); + const stateDir = path.join(os.homedir(), ".nemoclaw", "state", "runs", runId!); + const persistedPlan = fs.readFileSync(path.join(stateDir, "plan.json"), "utf8"); + expect(JSON.parse(persistedPlan)).toMatchObject({ + sandbox_created_by_apply: true, + identity: { + provider_type: providerType, + provider_name: providerName, + credential_key: credentialKey, + provider_created: true, + attachment_created: true, + }, + }); + for (const secret of redactionValues) expect(persistedPlan).not.toContain(secret); + + let placeholder = ""; + await expect + .poll( + async () => { + const probe = await sandbox.exec(sandboxName, ["/usr/bin/printenv", credentialKey], { + artifactName: `${artifactPrefix}-sandbox-placeholder`, + env: openshellEnv, + timeoutMs: 30_000, + }); + placeholder = probe.exitCode === 0 ? probe.stdout.trim() : ""; + return placeholder; + }, + { interval: 2_000, timeout: 35_000 }, + ) + .toMatch(new RegExp(`^openshell:resolve:env:(?:v[0-9]+_)?${credentialKey}$`)); + + const sandboxEnvironment = await sandbox.exec(sandboxName, ["/usr/bin/env"], { + artifactName: `${artifactPrefix}-sandbox-environment`, + env: openshellEnv, + timeoutMs: 30_000, + }); + expect(sandboxEnvironment.exitCode, resultText(sandboxEnvironment)).toBe(0); + expect(sandboxEnvironment.stdout).toContain(`${credentialKey}=${placeholder}`); + expect(sandboxEnvironment.stdout).not.toContain("OKTA_CLIENT_SECRET="); + expect(sandboxEnvironment.stdout).not.toContain("OKTA_SUBJECT_TOKEN="); + for (const secret of redactionValues) expect(sandboxEnvironment.stdout).not.toContain(secret); + + progress.phase("prove admitted bearer delivery and denied-request isolation"); + const allowed = await sandbox.exec( + sandboxName, + [ + "/usr/bin/curl", + "-fsS", + "-H", + `Authorization: Bearer ${placeholder}`, + `${tunnel.origin}${resourcePath}`, + ], + { + artifactName: `${artifactPrefix}-allowed-resource`, + env: openshellEnv, + timeoutMs: 60_000, + }, + ); + expect(allowed.exitCode, resultText(allowed)).toBe(0); + expect(JSON.parse(allowed.stdout)).toEqual({ authenticated: true, access_token_version: 1 }); + expect(oauth.resourceRequests()).toEqual([ + { method: "GET", path: resourcePath, auth: "ok", accessTokenVersion: 1 }, + ]); + + const admittedRequestCount = oauth.resourceRequests().length; + const denied = await sandbox.exec( + sandboxName, + [ + "/usr/bin/curl", + "-fsS", + "-X", + "POST", + "-H", + `Authorization: Bearer ${placeholder}`, + `${tunnel.origin}${resourcePath}`, + ], + { + artifactName: `${artifactPrefix}-denied-resource`, + env: openshellEnv, + timeoutMs: 60_000, + }, + ); + expect(denied.exitCode, resultText(denied)).not.toBe(0); + expect(oauth.resourceRequests()).toHaveLength(admittedRequestCount); + + progress.phase("rollback the owned OBO sandbox and provider"); + const rollback = await runRawCommand( + process.execPath, + [ + tsxPath, + "--input-type=module", + "--eval", + `const { main } = await import(${JSON.stringify(runnerPath)}); await main(["rollback", "--run-id", ${JSON.stringify(runId)}]);`, + ], + { + artifactName: `${artifactPrefix}-runtime-identity-rollback`, + artifacts, + cwd: workdir, + env: runnerEnv, + progress, + redactionValues, + timeoutMs: 2 * 60_000, + }, + ); + expect(rollback.exitCode, resultText(rollback)).toBe(0); + expect(fs.existsSync(path.join(stateDir, "rolled_back"))).toBe(true); + + const providerAfterRollback = await sandbox.openshell(["provider", "get", providerName], { + artifactName: `${artifactPrefix}-provider-after-rollback`, + env: openshellEnv, + timeoutMs: 30_000, + }); + expect(providerAfterRollback.exitCode).not.toBe(0); + const sandboxAfterRollback = await sandbox.openshell(["sandbox", "get", sandboxName], { + artifactName: `${artifactPrefix}-sandbox-after-rollback`, + env: openshellEnv, + timeoutMs: 30_000, + }); + expect(sandboxAfterRollback.exitCode).not.toBe(0); + + const deleteProfile = await sandbox.openshell(["provider", "profile", "delete", providerType], { + artifactName: `${artifactPrefix}-delete-conformance-profile`, + env: openshellEnv, + timeoutMs: 30_000, + }); + expect(deleteProfile.exitCode, resultText(deleteProfile)).toBe(0); +} From 0c43dd3ab7ae96e126790c75d555fed8281fb27f Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 4 Aug 2026 09:06:52 -0700 Subject: [PATCH 05/10] fix(test): expose destructured OBO fixtures Signed-off-by: Alex Fournier --- test/e2e/live/runtime-identity-obo-scenario.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/e2e/live/runtime-identity-obo-scenario.ts b/test/e2e/live/runtime-identity-obo-scenario.ts index 4d22f0e2253..6dde3b23666 100644 --- a/test/e2e/live/runtime-identity-obo-scenario.ts +++ b/test/e2e/live/runtime-identity-obo-scenario.ts @@ -43,10 +43,14 @@ export const RUNTIME_IDENTITY_OBO_E2E_OPTIONS = { }, } as const; -export async function runRuntimeIdentityOboE2EScenario( - context: RuntimeIdentityOboContext, -): Promise { - const { artifacts, cleanup, host, progress, sandbox, skip } = context; +export async function runRuntimeIdentityOboE2EScenario({ + artifacts, + cleanup, + host, + progress, + sandbox, + skip, +}: RuntimeIdentityOboContext): Promise { const artifactPrefix = "tc-inf-14"; await requireLivePrerequisites(host, skip); From eed88b1e9d5539cac22afd2be62e8654f5cb16a0 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 4 Aug 2026 10:09:38 -0700 Subject: [PATCH 06/10] fix(test): avoid OBO sandbox forward collision Signed-off-by: Alex Fournier --- test/e2e/live/runtime-identity-obo-scenario.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/e2e/live/runtime-identity-obo-scenario.ts b/test/e2e/live/runtime-identity-obo-scenario.ts index 6dde3b23666..356b2d835cd 100644 --- a/test/e2e/live/runtime-identity-obo-scenario.ts +++ b/test/e2e/live/runtime-identity-obo-scenario.ts @@ -246,6 +246,7 @@ export async function runRuntimeIdentityOboE2EScenario({ " sandbox:", " image: openclaw", ` name: ${sandboxName}`, + " forward_ports: []", " inference:", " profiles:", " default:", From 48a38ff5f34968d3d26d13610d891871a3fef708 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 4 Aug 2026 12:52:24 -0700 Subject: [PATCH 07/10] test(identity): expose OBO inference fixture Signed-off-by: Alex Fournier --- test/e2e/live/runtime-identity-obo-scenario.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/test/e2e/live/runtime-identity-obo-scenario.ts b/test/e2e/live/runtime-identity-obo-scenario.ts index 356b2d835cd..e74c95a216e 100644 --- a/test/e2e/live/runtime-identity-obo-scenario.ts +++ b/test/e2e/live/runtime-identity-obo-scenario.ts @@ -64,6 +64,7 @@ export async function runRuntimeIdentityOboE2EScenario({ const model = "nemoclaw-e2e-runtime-identity-obo"; const inferenceKey = "sk-runtime-identity-obo-TEST-NOT-A-REAL-VALUE"; + const inferencePort = 8000; const prerequisiteSandboxName = inferenceSandboxName("e2e-tc-inf-14-prerequisite"); const sandboxName = inferenceSandboxName("e2e-tc-inf-14"); const providerType = "okta-obo-v1"; @@ -96,13 +97,24 @@ export async function runRuntimeIdentityOboE2EScenario({ chatContent: "PONG", host: "0.0.0.0", model, - port: 8000, + port: inferencePort, progress, publicHost: "localhost", requireAuth: true, requireAuthModels: true, }); cleanup.add("close OBO runtime identity inference prerequisite", () => inference.close()); + const cloudflaredBin = await resolveVerifiedCloudflaredBinary(cleanup, host); + const inferenceTunnel = await startPublicMcpHttpsTunnel({ + cloudflaredBin, + cleanup, + label: "runtime identity OBO inference prerequisite", + progress, + readinessPath: "/v1/models", + readinessStatus: 401, + server: { port: inferencePort, close: () => inference.close() }, + }); + const inferenceEndpoint = `${inferenceTunnel.origin}/v1`; progress.phase("establish the reusable inference route"); const onboard = await onboardSandbox( @@ -110,7 +122,7 @@ export async function runRuntimeIdentityOboE2EScenario({ prerequisiteSandboxName, { COMPATIBLE_API_KEY: inferenceKey, - NEMOCLAW_ENDPOINT_URL: inference.baseUrl, + NEMOCLAW_ENDPOINT_URL: inferenceEndpoint, NEMOCLAW_MODEL: model, NEMOCLAW_PREFERRED_API: "openai-completions", NEMOCLAW_PROVIDER: "custom", @@ -194,7 +206,6 @@ export async function runRuntimeIdentityOboE2EScenario({ await oauth.close(); } }); - const cloudflaredBin = await resolveVerifiedCloudflaredBinary(cleanup, host); const tunnel = await startPublicMcpHttpsTunnel({ cloudflaredBin, cleanup, From 12ea51bdb6f2a5f0902178df265b7f4c5bb8bfbc Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 4 Aug 2026 13:40:52 -0700 Subject: [PATCH 08/10] test(identity): use HTTPS OBO inference fixture Signed-off-by: Alex Fournier --- test/e2e/live/runtime-identity-obo-scenario.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/test/e2e/live/runtime-identity-obo-scenario.ts b/test/e2e/live/runtime-identity-obo-scenario.ts index e74c95a216e..ad7e9102064 100644 --- a/test/e2e/live/runtime-identity-obo-scenario.ts +++ b/test/e2e/live/runtime-identity-obo-scenario.ts @@ -8,9 +8,9 @@ import path from "node:path"; import { buildAvailabilityProbeEnv } from "../fixtures/availability-env.ts"; import { resultText } from "../fixtures/clients/command.ts"; import { type E2ETargetFixtures, expect } from "../fixtures/e2e-test.ts"; -import { startFakeOpenAiCompatibleServer } from "../fixtures/fake-openai-compatible.ts"; import { REPO_ROOT } from "../fixtures/paths.ts"; import { resolveVerifiedCloudflaredBinary } from "./cloudflared-prerequisite.ts"; +import { startFakeHttpsCompatibleServer } from "./https-pin-compatible-server.ts"; import { cleanupSandbox, expectOnboardSuccess, @@ -64,7 +64,6 @@ export async function runRuntimeIdentityOboE2EScenario({ const model = "nemoclaw-e2e-runtime-identity-obo"; const inferenceKey = "sk-runtime-identity-obo-TEST-NOT-A-REAL-VALUE"; - const inferencePort = 8000; const prerequisiteSandboxName = inferenceSandboxName("e2e-tc-inf-14-prerequisite"); const sandboxName = inferenceSandboxName("e2e-tc-inf-14"); const providerType = "okta-obo-v1"; @@ -92,16 +91,10 @@ export async function runRuntimeIdentityOboE2EScenario({ await cleanupSandbox(host, sandbox, sandboxName); await cleanupSandbox(host, sandbox, prerequisiteSandboxName); - const inference = await startFakeOpenAiCompatibleServer({ + const inference = await startFakeHttpsCompatibleServer({ apiKey: inferenceKey, chatContent: "PONG", - host: "0.0.0.0", model, - port: inferencePort, - progress, - publicHost: "localhost", - requireAuth: true, - requireAuthModels: true, }); cleanup.add("close OBO runtime identity inference prerequisite", () => inference.close()); const cloudflaredBin = await resolveVerifiedCloudflaredBinary(cleanup, host); @@ -112,7 +105,7 @@ export async function runRuntimeIdentityOboE2EScenario({ progress, readinessPath: "/v1/models", readinessStatus: 401, - server: { port: inferencePort, close: () => inference.close() }, + server: inference, }); const inferenceEndpoint = `${inferenceTunnel.origin}/v1`; From fc587046f70d9573437540b9846aedc89b8f3ed6 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 4 Aug 2026 14:14:19 -0700 Subject: [PATCH 09/10] test(identity): scope OBO sandbox reuse assertion Signed-off-by: Alex Fournier --- test/e2e/live/runtime-identity-obo-scenario.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/live/runtime-identity-obo-scenario.ts b/test/e2e/live/runtime-identity-obo-scenario.ts index ad7e9102064..466be04148a 100644 --- a/test/e2e/live/runtime-identity-obo-scenario.ts +++ b/test/e2e/live/runtime-identity-obo-scenario.ts @@ -344,7 +344,7 @@ export async function runRuntimeIdentityOboE2EScenario({ const applyText = resultText(apply); expect(apply.exitCode, applyText).toBe(0); expect(applyText).toContain(`Sandbox '${sandboxName}' is ready.`); - expect(applyText).not.toContain("already exists, reusing"); + expect(applyText).not.toContain(`Sandbox '${sandboxName}' already exists, reusing.`); for (const secret of redactionValues) expect(applyText).not.toContain(secret); expect(oauth.tokenExchangeRequests()).toEqual([ { From 2430fb37166b4707e7433e362f3a188194f90793 Mon Sep 17 00:00:00 2001 From: Alex Fournier Date: Tue, 4 Aug 2026 14:54:43 -0700 Subject: [PATCH 10/10] test(identity): compare OBO endpoint host exactly Signed-off-by: Alex Fournier --- nemoclaw/src/blueprint/runtime-identity.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nemoclaw/src/blueprint/runtime-identity.test.ts b/nemoclaw/src/blueprint/runtime-identity.test.ts index 04e915a1dce..f9f2f98e12d 100644 --- a/nemoclaw/src/blueprint/runtime-identity.test.ts +++ b/nemoclaw/src/blueprint/runtime-identity.test.ts @@ -459,7 +459,7 @@ describe("runtime identity contract", () => { writeFileSync(join(root, oboConfig.profile_path), oboProfileDocument); environment.OKTA_SUBJECT_TOKEN = "user-subject-token"; deps.validateEndpointUrl = async (url) => - url.includes("api.example.com") + new URL(url).hostname === "api.example.com" ? Promise.reject(new Error("private destination rejected")) : Promise.resolve({ dnsResolved: false });