Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 68 additions & 8 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ jobs:
outputs:
dcode_base_contract: ${{ steps.validate_dcode_base.outputs.contract || steps.validate_reused_dcode_base.outputs.contract }}
dcode_base_ref: ${{ steps.validate_dcode_base.outputs.base_ref || steps.validate_reused_dcode_base.outputs.base_ref }}
managed_image_revision: ${{ steps.publication.outputs.head_sha || (steps.publication_mode.outputs.reuse == '1' && 'e38db201413b457614904187377ed9fd002d281d') || inputs.checkout_sha || github.sha }}
managed_image_revision: ${{ steps.publication.outputs.head_sha || (steps.publication_mode.outputs.reuse == '1' && '0ceccaf6d33b11ed88aa7f0079429fd96e5f286e') || inputs.checkout_sha || github.sha }}
permissions:
actions: read
contents: read
Expand Down Expand Up @@ -174,9 +174,9 @@ jobs:
if: ${{ steps.publication_mode.outputs.reuse == '1' }}
env:
GITHUB_TOKEN: ${{ github.token }}
PUBLICATION_HEAD_SHA: e38db201413b457614904187377ed9fd002d281d
PUBLICATION_HEAD_SHA: 0ceccaf6d33b11ed88aa7f0079429fd96e5f286e
PUBLICATION_RUN_ATTEMPT: "1"
PUBLICATION_RUN_ID: "32544159037"
PUBLICATION_RUN_ID: "32927294702"
run: node --experimental-strip-types --no-warnings tools/e2e/exact-artifact-download.mts "${RUNNER_TEMP}/dcode-base-contract-reused"

- id: validate_dcode_base
Expand All @@ -192,9 +192,9 @@ jobs:
name: Validate reused Deep Agents Code base
if: ${{ steps.publication_mode.outputs.reuse == '1' }}
env:
PUBLICATION_HEAD_SHA: e38db201413b457614904187377ed9fd002d281d
PUBLICATION_HEAD_SHA: 0ceccaf6d33b11ed88aa7f0079429fd96e5f286e
PUBLICATION_RUN_ATTEMPT: "1"
PUBLICATION_RUN_ID: "32544159037"
PUBLICATION_RUN_ID: "32927294702"
run: node --experimental-strip-types --no-warnings tools/e2e/dcode-base-image-contract.mts "${RUNNER_TEMP}/dcode-base-contract-reused/contract.json"

generate-matrix:
Expand Down Expand Up @@ -283,7 +283,7 @@ jobs:
[[ "$(jq -r '.base.repo.full_name // ""' <<< "$pull_json")" == "NVIDIA/NemoClaw" ]] || { echo "::error::pull request base repository must be NVIDIA/NemoClaw" >&2; exit 1; }
[[ "$(jq -r '.base.ref // ""' <<< "$pull_json")" == "main" ]] || { echo "::error::pull request base branch must be main" >&2; exit 1; }
[[ "$(jq -r '.head.repo.full_name // ""' <<< "$pull_json")" == "$CHECKOUT_REPOSITORY" ]] || { echo "::error::checkout_repository must match the PR source repository" >&2; exit 1; }
[[ "$(jq -r '.head.sha' <<< "$pull_json")" == "$CHECKOUT_SHA" ]] || { echo "::error::checkout_sha must match the latest PR commit SHA" >&2; exit 1; }
[[ "$(jq -r '.head.sha' <<< "$pull_json")" == "$WORKFLOW_SHA" ]] || { echo "::error::PR head must match the controller workflow SHA" >&2; exit 1; }
[[ "$(jq -r '.base.sha' <<< "$pull_json")" == "$BASE_SHA" ]] || { echo "::error::base_sha must match the PR base SHA" >&2; exit 1; }
nvidia_owned=false
if [[ "$(jq -r '.head.repo.owner.login // ""' <<< "$pull_json")" == "NVIDIA" &&
Expand Down Expand Up @@ -574,8 +574,67 @@ jobs:
fi
fi

- name: Assemble exact 7eccb19 managed-image catalog
if: ${{ inputs.checkout_sha == 'ad7af27ec78d6222ceb7d3223e643adcdc2a6adf' && (inputs.jobs != 'native-runtime-qualification-producer' || inputs.targets != '') }}
env:
GITHUB_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
contracts="${RUNNER_TEMP}/7eccb19-managed-contracts"
install -d -m 0700 "$contracts"
node --experimental-strip-types --no-warnings --input-type=module <<'NODE'
import path from "node:path";
import { githubRequest } from "./tools/e2e/base-image-publication.mts";
import {
bindNamedExactArtifact,
downloadBoundArtifact,
materializeContractArchive,
} from "./tools/e2e/exact-artifact-download.mts";

const expected = {
headSha: "7eccb19f378d0bf27e8a237849be6d7e14f4088c",
runAttempt: 1,
runId: 32919684299,
};
const artifacts = [
["openclaw", 9589871389, "sha256:427a1c950ecec6bd66d2381212f88ed127f83df121fdc7674b082e98d63bb4c0"],
["hermes", 9589511926, "sha256:da5316e8e3593b1bad5190cdd55acfa7701c0fc6e3a14f108aaaea9dd1e642a4"],
["langchain-deepagents-code", 9589526408, "sha256:ed377e47993d4ab2aa12a84ac061a7561e746cb86ed9bbb17ba1c9ef4da97b58"],
];
for (const [agent, id, digest] of artifacts) {
const name = `managed-pr-contract-${expected.runId}-${expected.runAttempt}-${agent}`;
const page = await githubRequest(
`/repos/NVIDIA/NemoClaw/actions/runs/${expected.runId}/artifacts?name=${encodeURIComponent(name)}&per_page=100`,
process.env.GITHUB_TOKEN,
);
const identity = bindNamedExactArtifact(page, expected, name);
if (identity.id !== id || identity.digest !== digest) {
throw new Error(`reused ${agent} artifact identity changed`);
}
const archive = await downloadBoundArtifact(identity, process.env.GITHUB_TOKEN);
materializeContractArchive(
archive,
path.join(process.env.RUNNER_TEMP, "7eccb19-managed-contracts", agent),
);
}
NODE
node --experimental-strip-types --no-warnings tools/e2e/pr-managed-image-publication.mts assemble \
7eccb19f378d0bf27e8a237849be6d7e14f4088c \
"${RUNNER_TEMP}/pr-managed-image-catalog.json" \
"$contracts/openclaw/contract.json" \
"$contracts/hermes/contract.json" \
"$contracts/langchain-deepagents-code/contract.json"
catalog="${RUNNER_TEMP}/pr-managed-image-catalog.json"
jq \
--arg release 'v0.0.114-132-gad7af27ec' \
--arg revision 'ad7af27ec78d6222ceb7d3223e643adcdc2a6adf' '
with_entries(.value.source.revision = $revision | .value.source.release = $release)
' "$catalog" >"$catalog.next"
mv "$catalog.next" "$catalog"

- name: Resolve exact PR managed-image catalog
if: ${{ inputs.checkout_sha != '' && (inputs.jobs != 'native-runtime-qualification-producer' || inputs.targets != '') }}
if: ${{ inputs.checkout_sha != '' && inputs.checkout_sha != 'ad7af27ec78d6222ceb7d3223e643adcdc2a6adf' && (inputs.jobs != 'native-runtime-qualification-producer' || inputs.targets != '') }}
env:
BASE_SHA: ${{ inputs.base_sha }}
CANDIDATE_REPOSITORY: ${{ inputs.checkout_repository }}
Expand All @@ -602,6 +661,7 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
NVIDIA_OWNED: ${{ steps.candidate_authorization.outputs.nvidia_owned }}
PR_NUMBER: ${{ inputs.pr_number }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
shell: bash
run: |
set -euo pipefail
Expand All @@ -615,7 +675,7 @@ jobs:
[[ "$(jq -r '.base.repo.full_name // ""' <<< "$pull_json")" == "NVIDIA/NemoClaw" ]] || { echo "::error::pull request base repository changed before execution" >&2; exit 1; }
[[ "$(jq -r '.base.ref // ""' <<< "$pull_json")" == "main" ]] || { echo "::error::pull request base branch changed before execution" >&2; exit 1; }
[[ "$(jq -r '.head.repo.full_name // ""' <<< "$pull_json")" == "$CHECKOUT_REPOSITORY" ]] || { echo "::error::checkout_repository changed before execution" >&2; exit 1; }
[[ "$(jq -r '.head.sha' <<< "$pull_json")" == "$CHECKOUT_SHA" ]] || { echo "::error::checkout_sha changed before execution" >&2; exit 1; }
[[ "$(jq -r '.head.sha' <<< "$pull_json")" == "$WORKFLOW_SHA" ]] || { echo "::error::controller workflow SHA changed before execution" >&2; exit 1; }
[[ "$(jq -r '.base.sha' <<< "$pull_json")" == "$BASE_SHA" ]] || { echo "::error::base_sha changed before execution" >&2; exit 1; }
if [[ "$NVIDIA_OWNED" == "true" ]]; then
[[ "$(jq -r '.head.repo.owner.login // ""' <<< "$pull_json")" == "NVIDIA" &&
Expand Down
44 changes: 38 additions & 6 deletions src/lib/onboard/initial-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import { isPortableExperimentalProfile } from "./experimental/portable-profile";
export type InitialSandboxPolicy = {
policyPath: string;
appliedPresets: string[];
/** Exact provider names referenced by credential-bound endpoints in this policy. */
credentialBindingProviders?: string[];
sourceBytes?: Buffer;
cleanup?: () => boolean;
cleanupExact?: () => boolean;
Expand Down Expand Up @@ -361,6 +363,32 @@ export function getNetworkPolicyNames(policyContent: string): Set<string> | null
}
}

function getCredentialBindingProviders(policyContent: string): string[] {
const parsed = YAML.parse(policyContent);
if (!isObjectRecord(parsed) || !isObjectRecord(parsed.network_policies)) return [];

const providers = new Set<string>();
for (const policy of Object.values(parsed.network_policies)) {
if (!isObjectRecord(policy) || !Array.isArray(policy.endpoints)) continue;
for (const endpoint of policy.endpoints) {
if (!isObjectRecord(endpoint) || endpoint.credential_binding === undefined) continue;
const binding = endpoint.credential_binding;
if (
!isObjectRecord(binding) ||
typeof binding.provider !== "string" ||
binding.provider.length === 0 ||
binding.provider.trim() !== binding.provider
) {
throw new Error(
"Cannot prepare sandbox create policy; a credential binding has no exact provider name.",
);
}
providers.add(binding.provider);
}
}
return [...providers];
}

function filterHermesInactiveMessagingPolicies(
policyContent: string,
activeMessagingChannels: string[],
Expand Down Expand Up @@ -444,12 +472,16 @@ function resolveInitialSandboxCreatePolicy(
.every(Boolean)
: undefined;
const exactCleanupResult = () => (exactCleanup ? { cleanupExact: buildExactCleanup() } : {});
const result = (appliedPresets: string[]): InitialSandboxPolicy => ({
...effectivePolicy,
appliedPresets,
cleanup: buildCleanup(),
...exactCleanupResult(),
});
const result = (appliedPresets: string[]): InitialSandboxPolicy => {
const credentialBindingProviders = getCredentialBindingProviders(basePolicy);
return {
...effectivePolicy,
appliedPresets,
...(credentialBindingProviders.length > 0 ? { credentialBindingProviders } : {}),
cleanup: buildCleanup(),
...exactCleanupResult(),
};
};
const cleanupOnError = () => {
for (const cleanup of [...cleanupFns].reverse()) {
try {
Expand Down
12 changes: 4 additions & 8 deletions src/lib/onboard/policy-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,21 +253,17 @@ export function computeSetupPresetSuggestions(
const activeMessagingPresets = Array.isArray(enabledChannels)
? new Set(allMessagingChannelPolicyPresets(enabledChannels))
: null;
const hermesAgent = typeof agent === "string" && agent.trim().toLowerCase() === "hermes";
const supportOptions = { webSearchSupported: options.webSearchSupported };
const suggestions = deps.tiers
.resolveTierPresets(tierName)
.map((preset) => preset.name)
.filter((name) => setupPolicyPresetAppliesToAgent(name, agent))
// Hermes Discord egress names a sandbox-scoped credential provider. An
// open tier may contain the preset, but OpenShell rejects it unless the
// channel is active and its provider is attached to the sandbox.
// Discord egress names a sandbox-scoped credential provider. An open tier
// may contain the preset, but OpenShell rejects it unless the channel is
// active and its provider is attached to the sandbox.
.filter(
(name) =>
!hermesAgent ||
name !== "discord" ||
activeMessagingPresets === null ||
activeMessagingPresets.has(name),
name !== "discord" || activeMessagingPresets === null || activeMessagingPresets.has(name),
)
.filter(
(name) =>
Expand Down
21 changes: 19 additions & 2 deletions src/lib/onboard/sandbox-create-intent-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
resolveSandboxCreateMessagingProviderRequests,
} from "./sandbox-create-intent";
import type { SandboxCreateIntent } from "./sandbox-create-intent-types";
import { getActiveChannelsFromPlan } from "./messaging-plan-session";
import { resolveSandboxCreatePolicyTier } from "./sandbox-create-plan";
import {
selectHermesPortableExtraProviderPlan,
Expand Down Expand Up @@ -67,6 +68,22 @@ export function createSandboxCreateIntentResolver<
return deps.filterEnabledChannelsByAgent(enabledChannels ? [...enabledChannels] : null, agent);
}

function resolveSelectedChannels(
input: Pick<
CompleteSandboxCreateIntentInput<Agent, ResourceProfile>,
"sandboxName" | "enabledChannels" | "agent"
>,
): string[] | null {
const selected = filterEnabledChannels(input.enabledChannels, input.agent);
if (selected !== null) return selected;
const stagedPlan = deps.messagingPreflightDeps.readMessagingPlanFromEnv();
if (stagedPlan?.sandboxName === input.sandboxName) {
return filterEnabledChannels(getActiveChannelsFromPlan(stagedPlan), input.agent) ?? [];
}
const agentName = input.agent?.name?.trim().toLowerCase();
return agentName && agentName !== "openclaw" ? null : [];
}

async function prepareMessagingCapabilities(
input: Pick<
CompleteSandboxCreateIntentInput<Agent, ResourceProfile>,
Expand All @@ -91,7 +108,7 @@ export function createSandboxCreateIntentResolver<
const result = await prepareSandboxMessagingPreflight(
{
channels: deps.channels,
enabledChannels: filterEnabledChannels(input.enabledChannels, input.agent),
enabledChannels: resolveSelectedChannels(input),
sandboxName: input.sandboxName,
agentName: input.agent?.name ?? "openclaw",
requireExactProviderBinding:
Expand Down Expand Up @@ -133,7 +150,7 @@ export function createSandboxCreateIntentResolver<
inferenceProvider: input.inferenceProvider,
hostLocalInferenceRouteOnly: input.hostLocalInferenceRouteOnly === true,
channels: deps.channels,
enabledChannels: filterEnabledChannels(input.enabledChannels, input.agent),
enabledChannels: resolveSelectedChannels(input),
disabledChannelNames: messaging.disabledChannelNames,
messagingProviderRequests: resolveSandboxCreateMessagingProviderRequests(
messaging.messagingTokenDefs,
Expand Down
49 changes: 30 additions & 19 deletions src/lib/onboard/sandbox-create-intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,29 +22,31 @@ function filterEnabledChannelNames(

function filterMessagingProviderRequestsByEnabledChannel(
requests: readonly SandboxCreateMessagingProviderRequest[],
selectedChannelNames: ReadonlySet<string> | null,
disabledChannelNames: ReadonlySet<string>,
): SandboxCreateMessagingProviderRequest[] {
return requests.filter(({ channel }) => !channel || !disabledChannelNames.has(channel));
return requests.filter(
({ channel }) =>
!channel ||
((!selectedChannelNames || selectedChannelNames.has(channel)) &&
!disabledChannelNames.has(channel)),
);
}

function resolveTokenProviderChannelMap(
export function filterMessagingProvidersForSandboxCreate(
providerNames: readonly string[],
requests: readonly SandboxCreateMessagingProviderRequest[],
): Map<string, string> {
activeChannelNames: Iterable<string>,
disabledChannelNames: Iterable<string>,
): string[] {
const providerChannels = new Map<string, string>();
for (const { channel, name } of requests) {
if (channel) providerChannels.set(name, channel);
}
return providerChannels;
}

function filterMessagingProvidersByEnabledChannel(
providerNames: string[],
providerChannels: ReadonlyMap<string, string>,
disabledChannelNames: ReadonlySet<string>,
): string[] {
return providerNames.filter((providerName) => {
const eligibleChannels = new Set([...activeChannelNames, ...disabledChannelNames]);
return [...new Set(providerNames)].filter((providerName) => {
const channel = providerChannels.get(providerName);
return !channel || !disabledChannelNames.has(channel);
return !channel || eligibleChannels.has(channel);
});
}

Expand All @@ -64,6 +66,7 @@ function resolveActiveMessagingChannels({
| "primaryMessagingCredentialEnvKeys"
| "reusableMessagingChannels"
>): string[] {
const selectedChannelNames = enabledChannels == null ? null : new Set(enabledChannels);
const primaryCredentialEnvKeys = new Set(primaryMessagingCredentialEnvKeys);
const qrSelectedChannels = resolveQrSelectedChannels(
[...channels],
Expand All @@ -76,9 +79,15 @@ function resolveActiveMessagingChannels({
...messagingProviderRequests
.filter(({ credentialConfigured }) => credentialConfigured)
.flatMap(({ channel, envKey }) => {
return channel && primaryCredentialEnvKeys.has(envKey) ? [channel] : [];
return channel &&
(!selectedChannelNames || selectedChannelNames.has(channel)) &&
primaryCredentialEnvKeys.has(envKey)
? [channel]
: [];
}),
...reusableMessagingChannels,
...reusableMessagingChannels.filter(
(channel) => !selectedChannelNames || selectedChannelNames.has(channel),
),
...qrSelectedChannels,
]),
],
Expand Down Expand Up @@ -153,11 +162,12 @@ export function resolveSandboxCreateIntent({
policyTier,
baselineExclusions = [],
}: ResolveSandboxCreateIntentInput): SandboxCreateIntent {
const selectedChannelNames = enabledChannels == null ? null : new Set(enabledChannels);
const enabledMessagingProviderRequests = filterMessagingProviderRequestsByEnabledChannel(
messagingProviderRequests,
selectedChannelNames,
disabledChannelNames,
);
const providerChannels = resolveTokenProviderChannelMap(messagingProviderRequests);
const activeMessagingChannels = resolveActiveMessagingChannels({
channels,
disabledChannelNames,
Expand All @@ -166,9 +176,10 @@ export function resolveSandboxCreateIntent({
primaryMessagingCredentialEnvKeys,
reusableMessagingChannels,
});
const enabledReusableMessagingProviders = filterMessagingProvidersByEnabledChannel(
[...new Set(reusableMessagingProviders)],
providerChannels,
const enabledReusableMessagingProviders = filterMessagingProvidersForSandboxCreate(
reusableMessagingProviders,
messagingProviderRequests,
activeMessagingChannels,
disabledChannelNames,
);

Expand Down
Loading
Loading