Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
1fee71b
feat(blueprint): add Okta runtime identity wrapper
afourniernv Jul 20, 2026
85292df
feat(blueprint): add Entra identity reference
afourniernv Jul 20, 2026
2a7ef35
fix(blueprint): keep runtime identity Okta-only
afourniernv Jul 20, 2026
590e385
fix(blueprint): harden Okta identity validation
afourniernv Jul 21, 2026
cb6d516
Merge branch 'main' into 6871-agent-identity-references/af
ericksoa Jul 23, 2026
b990376
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa Jul 27, 2026
3518f36
fix(blueprint): harden runtime identity lifecycle
ericksoa Jul 27, 2026
d35d4c6
fix(blueprint): close identity review gaps
ericksoa Jul 27, 2026
537c216
fix(blueprint): address identity review findings
ericksoa Jul 27, 2026
bdcd239
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa Jul 27, 2026
9a484a7
test(blueprint): keep identity fixtures linear
ericksoa Jul 27, 2026
182dc26
fix(blueprint): fail closed on identity DNS rebinding
ericksoa Jul 27, 2026
549106e
fix(blueprint): require owned identity providers
ericksoa Jul 27, 2026
a78525e
fix(blueprint): constrain runtime identity policy
ericksoa Jul 27, 2026
71e4496
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa Jul 27, 2026
4b32c5e
fix(blueprint): import validated identity profile bytes
ericksoa Jul 27, 2026
2a5c17c
test(blueprint): keep identity lifecycle linear
ericksoa Jul 27, 2026
0242437
fix(identity): prove runtime OAuth flow end to end
ericksoa Jul 27, 2026
7b79ce8
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa Jul 27, 2026
f7df500
test(identity): keep rotation proof linear
ericksoa Jul 27, 2026
0bc4646
fix(identity): tighten ownership and DNS trust
ericksoa Jul 27, 2026
5497274
fix(identity): bind client ID source to policy
ericksoa Jul 27, 2026
72e1193
fix(identity): keep failed rollback retryable
ericksoa Jul 27, 2026
b07fca5
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa Jul 27, 2026
7a85de9
fix(identity): reuse existing sandbox before create
ericksoa Jul 27, 2026
f2cbbf0
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa Jul 27, 2026
ff871ea
fix(identity): fail closed on sandbox inspection
ericksoa Jul 27, 2026
258f034
docs(identity): document sandbox preflight order
ericksoa Jul 27, 2026
d717806
test(identity): keep sandbox fixture linear
ericksoa Jul 27, 2026
0718158
fix(identity): reuse configured inference provider
ericksoa Jul 27, 2026
e0b5470
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa Jul 27, 2026
616c896
docs(identity): clarify provider preflight order
ericksoa Jul 27, 2026
7a35fc5
fix(identity): track inference provider ownership
ericksoa Jul 27, 2026
fa7a376
fix(identity): revalidate provider create races
ericksoa Jul 27, 2026
8fc7df8
fix(blueprint): verify inference before identity attach
ericksoa Jul 27, 2026
8ea8850
fix(blueprint): preserve active inference route
ericksoa Jul 27, 2026
20c64fb
docs(architecture): clarify route timeout reuse
ericksoa Jul 27, 2026
58dcf6e
test(blueprint): keep route timeout case linear
ericksoa Jul 27, 2026
1734e67
fix(blueprint): revalidate raced sandbox
ericksoa Jul 27, 2026
ba3b1ec
fix(blueprint): recheck sandbox before identity attach
ericksoa Jul 27, 2026
3d71424
fix(schema): align runtime client id names
ericksoa Jul 28, 2026
0fcf798
fix(identity): prove provider policy auth path
ericksoa Jul 28, 2026
c5b1821
fix(identity): bound inference route parsing
ericksoa Jul 28, 2026
efb00ba
test(e2e): verify preserved sandbox with supported command
ericksoa Jul 28, 2026
7ea7370
test(e2e): align runtime identity phase evidence
ericksoa Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/reference/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,57 @@ flowchart LR
4. Apply. The runner executes the plan by calling `openshell` CLI commands.
5. Status. The runner reports current state.

### Experimental Runtime Identity

The direct blueprint runner can opt in to an Okta runtime-identity component.
This experimental reference capability is not enabled by the shipped blueprint, and normal `$$nemoclaw onboard` does not collect or provision its inputs.
It is for operators extending a reviewed blueprint with a tenant-specific Okta profile.

Enable provider-derived policy on the target gateway before applying a blueprint that attaches this component:

```bash
openshell settings set --global --key providers_v2_enabled --value true --yes
```

Copy `nemoclaw-blueprint/provider-profiles/okta-runtime-v1.yaml` into the blueprint you operate.
Set its `token_url` to the tenant's authorization-server token endpoint, replace `api.example.okta.com` with the approved upstream API host, and add only the API scopes that the Okta authorization server grants to `refresh.scopes`.
Keep the token endpoint and upstream host in the profile so refresh material and bearer-token presentation cannot be redirected by blueprint input.

Configure the reference under `components`:

```yaml
identity:
okta:
profile_path: provider-profiles/acme-okta-runtime.yaml
provider_type: okta-runtime-v1
provider_name: acme-okta-runtime
credential_key: OKTA_ACCESS_TOKEN
client_id_env: OKTA_CLIENT_ID
refresh_token_env: OKTA_REFRESH_TOKEN
client_secret_env: OKTA_CLIENT_SECRET
```
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

`client_secret_env` is optional, but when it is configured its environment variable must be present.
Before applying the blueprint, export the named environment variables in the host process that runs the direct blueprint runner.
Obtain the refresh token through an authorized OAuth bootstrap flow for the same Okta client and authorization server as the profile.
`openshell gateway login` authenticates a CLI user to the gateway and is not part of this runtime-credential flow.

During apply, the runner imports the profile, creates a runtime-credential provider, configures gateway-managed OAuth refresh, rotates an initial access token, and attaches the provider to the sandbox.
The refresh token and optional client secret are passed as secret material and stay in the gateway credential store.
The sandbox receives an opaque `OKTA_ACCESS_TOKEN` placeholder, and the OpenShell L7 proxy substitutes the current access token only for admitted HTTPS requests.

Check the gateway-side state without printing credential values:

```bash
openshell provider refresh status acme-okta-runtime --credential-key OKTA_ACCESS_TOKEN
openshell sandbox provider list <sandbox-name>
```

The same blueprint can optionally configure `components.policy.middlewares`.
Those entries use the OpenShell middleware API to extend the existing middleware chain with endpoint selection, ordering, and `fail_closed` behavior.
Middleware is optional for Okta access-token refresh and presentation.
Use it only when the deployment has a compatible middleware installed for additional controls, such as identity assertions or on-behalf-of exchange.

## Sandbox Environment

<AgentOnly variant="openclaw,hermes">
Expand Down
45 changes: 45 additions & 0 deletions nemoclaw-blueprint/provider-profiles/okta-runtime-v1.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

# Template profile for the experimental blueprint identity wrapper. Copy this
# file, replace the example authorization-server URL and API host, and add
# only tenant-approved API scopes under `refresh.scopes` before use.
id: okta-runtime-v1
display_name: Okta Runtime Credentials v1
description: Gateway-managed Okta access-token refresh for an attached sandbox
category: agent
credentials:
- name: OKTA_ACCESS_TOKEN
description: Short-lived Okta API access token
env_vars:
- OKTA_ACCESS_TOKEN
required: true
auth_style: bearer
header_name: authorization
refresh:
strategy: oauth2_refresh_token
token_url: https://example.okta.com/oauth2/default/v1/token
refresh_before_seconds: 300
max_lifetime_seconds: 3600
material:
- name: client_id
required: true
- name: refresh_token
required: true
secret: true
- name: client_secret
required: false
secret: true
endpoints:
- host: api.example.okta.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
222 changes: 222 additions & 0 deletions nemoclaw/src/blueprint/runner-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type fs from "node:fs";

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import YAML from "yaml";

interface FsEntry {
type: "file" | "dir";
content?: string;
}

const store = new Map<string, FsEntry>();
const mockExeca = vi.fn();

vi.mock("node:os", () => ({ homedir: () => "/fakehome" }));
vi.mock("node:crypto", () => ({ randomUUID: () => "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" }));
vi.mock("node:fs", async (importOriginal) => {
const original = await importOriginal<typeof fs>();
return {
...original,
mkdirSync: vi.fn((path: string) => store.set(path, { type: "dir" })),
readFileSync: (path: string) => {
const entry = store.get(path);
if (entry?.type !== "file") throw new Error(`ENOENT: ${path}`);
return entry.content ?? "";
},
writeFileSync: vi.fn((path: string, content: string) =>
store.set(path, { type: "file", content }),
),
readdirSync: (path: string) => {
const prefix = path.endsWith("/") ? path : `${path}/`;
const entries = [...store.keys()]
.filter((key) => key.startsWith(prefix))
.map((key) => key.slice(prefix.length).split("/")[0]);
if (entries.length === 0 && !store.has(path)) throw new Error(`ENOENT: ${path}`);
return [...new Set(entries)];
},
};
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
vi.mock("execa", () => ({ execa: (...args: unknown[]) => mockExeca(...args) }));
vi.mock("./ssrf.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./ssrf.js")>();
return {
...actual,
validateEndpointUrl: vi.fn(async (url: string) => ({
url,
pinnedUrl: url,
protocol: "https:",
hostname: new URL(url).hostname,
dnsResolved: false,
})),
};
});

const { actionApply, loadBlueprint } = await import("./runner.js");

function blueprint(overrides: Record<string, unknown> = {}): Parameters<typeof actionApply>[1] {
return {
components: {
inference: {
profiles: {
default: {
provider_type: "openai",
provider_name: "test-provider",
endpoint: "https://api.example.com/v1",
model: "test-model",
},
},
},
sandbox: { image: "openclaw", name: "test-sandbox", forward_ports: [18789] },
...overrides,
},
};
}

function oktaIdentity(profilePath = "provider-profiles/okta-runtime-v1.yaml") {
return {
profile_path: profilePath,
provider_type: "okta-runtime-v1",
provider_name: "acme-okta-runtime",
credential_key: "OKTA_ACCESS_TOKEN",
client_id_env: "OKTA_CLIENT_ID",
refresh_token_env: "OKTA_REFRESH_TOKEN",
client_secret_env: "OKTA_CLIENT_SECRET",
};
}

describe("blueprint identity wrapper", () => {
beforeEach(() => {
store.clear();
vi.clearAllMocks();
mockExeca.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
delete process.env.NEMOCLAW_BLUEPRINT_PATH;
});

afterEach(() => {
delete process.env.OKTA_CLIENT_ID;
delete process.env.OKTA_REFRESH_TOKEN;
delete process.env.OKTA_CLIENT_SECRET;
vi.restoreAllMocks();
});

it("accepts an opt-in Okta identity and fail-closed middleware configuration", () => {
const input = blueprint({
identity: { okta: oktaIdentity() },
policy: {
middlewares: {
identity_guard: {
middleware: "acme/identity-guard",
order: 10,
on_error: "fail_closed",
endpoints: { include: ["api.example.okta.com"] },
},
},
},
});
store.set("blueprint.yaml", { type: "file", content: YAML.stringify(input) });

expect(loadBlueprint()).toEqual(input);
});

it("rejects profile paths that escape the blueprint directory", async () => {
await expect(
actionApply("default", blueprint({ identity: { okta: oktaIdentity("../outside.yaml") } })),
).rejects.toThrow(/profile_path must stay inside/i);
});

it("configures refresh from scoped environment material and attaches the runtime provider", async () => {
process.env.OKTA_CLIENT_ID = "client-id";
process.env.OKTA_REFRESH_TOKEN = "refresh-secret";
process.env.OKTA_CLIENT_SECRET = "client-secret";

await actionApply("default", blueprint({ identity: { okta: oktaIdentity() } }));

expect(mockExeca).toHaveBeenCalledWith(
"openshell",
["provider", "profile", "import", "--file", "provider-profiles/okta-runtime-v1.yaml"],
expect.objectContaining({ reject: false }),
);
expect(mockExeca).toHaveBeenCalledWith(
"openshell",
[
"provider",
"create",
"--name",
"acme-okta-runtime",
"--type",
"okta-runtime-v1",
"--runtime-credentials",
],
expect.objectContaining({ reject: false }),
);
const refreshCall = mockExeca.mock.calls.find(
(call) => Array.isArray(call[1]) && call[1][0] === "provider" && call[1][2] === "configure",
);
if (!refreshCall) throw new Error("Okta refresh configure call not found");
expect(refreshCall[1]).toContain("client_id=client-id");
expect(refreshCall[1]).not.toContain("refresh-secret");
expect(refreshCall[1]).not.toContain("client-secret");
expect(refreshCall[2].env.OKTA_REFRESH_TOKEN).toBe("refresh-secret");
expect(refreshCall[2].env.OKTA_CLIENT_SECRET).toBe("client-secret");
expect(mockExeca).toHaveBeenCalledWith(
"openshell",
["sandbox", "provider", "attach", "test-sandbox", "acme-okta-runtime"],
expect.objectContaining({ reject: false }),
);
});

it("composes fail-closed middleware while retaining existing middleware", async () => {
mockExeca.mockImplementation(async (_command: string, args: string[]) => {
if (args[0] === "policy" && args[1] === "get") {
return {
exitCode: 0,
stdout: [
"Version: 1",
"Hash: sha256:test",
"---",
"version: 1",
"network_policies: {}",
"network_middlewares:",
" existing:",
" middleware: openshell/regex",
" endpoints:",
" include: [api.example.com]",
"",
].join("\n"),
stderr: "",
};
}
return { exitCode: 0, stdout: "", stderr: "" };
});
await actionApply(
"default",
blueprint({
policy: {
middlewares: {
identity_guard: {
middleware: "acme/identity-guard",
order: 10,
on_error: "fail_closed",
endpoints: { include: ["api.example.okta.com"] },
},
},
},
}),
);

const merged = [...store.entries()].find(([path]) => path.endsWith("/merged-policy.yaml"))?.[1]
.content;
if (!merged) throw new Error("merged policy file not written");
expect(YAML.parse(merged).network_middlewares).toMatchObject({
existing: { middleware: "openshell/regex" },
identity_guard: {
middleware: "acme/identity-guard",
order: 10,
on_error: "fail_closed",
},
});
});
});
Loading
Loading