diff --git a/src/lib/actions/sandbox/snapshot-managed-clone-handoff-dormancy.test.ts b/src/lib/actions/sandbox/snapshot-managed-clone-handoff-dormancy.test.ts new file mode 100644 index 00000000000..e0615132e29 --- /dev/null +++ b/src/lib/actions/sandbox/snapshot-managed-clone-handoff-dormancy.test.ts @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { managedStartupE2eProfile } from "../../../../scripts/checks/generate-managed-startup-profile-fixture.mts"; +import { MANAGED_IMAGE_REPOSITORIES } from "../../onboard/managed-image/contract"; +import { encodeManagedStartupProfile } from "../../onboard/managed-startup/profile"; +import * as fixture from "./snapshot-restore-test-fixture"; + +beforeEach(() => fixture.resetSnapshotRestoreMocks()); +afterEach(() => fixture.cleanupSnapshotRestoreMocks()); + +describe("managed snapshot clone activation boundary", () => { + it("rejects managed cross-sandbox restore before destination effects (#7744)", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const encodedProfile = encodeManagedStartupProfile(managedStartupE2eProfile("openclaw")); + fixture.getLatestBackupMock.mockReturnValue({ + snapshotVersion: 4, + timestamp: "2026-07-30T00:00:00.000Z", + backupPath: "/tmp/backup-alpha", + agentType: "openclaw", + workload: { + schemaVersion: 1, + kind: "managed-image", + reference: `${MANAGED_IMAGE_REPOSITORIES.openclaw}@sha256:${"a".repeat(64)}`, + platform: "linux/amd64", + release: "v0.0.100", + sourceRevision: "b".repeat(40), + sourceCohort: "ghrun-123-1", + capabilityContractVersion: 1, + startupProfileContractVersion: 1, + encodedProfile, + startupProfileSha256: createHash("sha256").update(encodedProfile, "utf8").digest("hex"), + credentialProxyReplayRequired: false, + shared: true, + }, + runtimeSnapshot: { + schemaVersion: 1, + providerId: "docker", + providerHandle: "snapshot-provider-handle", + lifecycleState: "running", + lifecycleGeneration: "snapshot-generation", + runtime: { + schemaVersion: 1, + providerId: "docker", + runtime: { kind: "docker-container", handle: "container-id" }, + acceleration: { kind: "none" }, + }, + }, + }); + fixture.getSandboxMock.mockImplementation((name) => + name === "alpha" ? { name: "alpha", agent: "openclaw", openshellDriver: "docker" } : null, + ); + const { runSandboxSnapshot } = await import("./snapshot"); + + await expect( + runSandboxSnapshot("alpha", { kind: "restore", to: "beta", force: true, yes: true }), + ).rejects.toMatchObject({ exitCode: 1 }); + + expect(consoleError.mock.calls.flat().join("\n")).toContain( + "requires managed-profile clone rebind", + ); + expect(fixture.lifecycleMock.events).not.toContain("delete"); + expect(fixture.streamSandboxCreateMock).not.toHaveBeenCalled(); + expect(fixture.restoreSandboxStateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/messaging/clone-rebind.ts b/src/lib/messaging/clone-rebind.ts new file mode 100644 index 00000000000..68b1c9c6778 --- /dev/null +++ b/src/lib/messaging/clone-rebind.ts @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { cloneAndDeepFreeze } from "../core/immutable"; +import { isValidName } from "../name-validation"; +import { createBuiltInChannelManifestRegistry } from "./channels/built-ins"; +import { resolveSandboxNameTemplate } from "./compiler/engines/template"; +import { hydrateDerivedSandboxMessagingPlanFields } from "./hydration"; +import type { MessagingAgentId, SandboxMessagingPlan } from "./manifest"; +import { compactSandboxMessagingPlanForPersistence } from "./persistence"; +import { parseSandboxMessagingPlan } from "./plan-validation"; + +export interface SandboxMessagingCloneRebindInput { + readonly sourceSandboxName: string; + readonly destinationSandboxName: string; + readonly agent: MessagingAgentId; + readonly sourcePlan: unknown; + /** + * Explicit non-secret inputs used by manifest renderers. Clone rebinding + * never consults process.env, so an unrelated host credential cannot change + * the destination plan or enter its fingerprints. + */ + readonly environment?: Readonly>; +} + +export class SandboxMessagingCloneRebindError extends Error { + constructor(message: string) { + super(`Cannot rebind managed messaging plan: ${message}`); + this.name = "SandboxMessagingCloneRebindError"; + } +} + +function fail(message: string): never { + throw new SandboxMessagingCloneRebindError(message); +} + +function requireSandboxName(value: string, label: string): string { + if (!isValidName(value)) fail(`${label} sandbox name is invalid`); + return value; +} + +/** + * Recompile one secret-free managed messaging plan for a destination sandbox. + * + * Compact persistence data is the retained intent boundary. All target-bound + * provider names and executable derived fields are rebuilt from current + * built-in manifests with an explicit, credential-free environment. + */ +export function rebindSandboxMessagingPlanForClone( + input: SandboxMessagingCloneRebindInput, +): SandboxMessagingPlan { + const sourceSandboxName = requireSandboxName(input.sourceSandboxName, "source"); + const destinationSandboxName = requireSandboxName(input.destinationSandboxName, "destination"); + if (sourceSandboxName === destinationSandboxName) { + fail("source and destination sandbox names must differ"); + } + + const manifestRegistry = createBuiltInChannelManifestRegistry(); + const supportedChannelIds = manifestRegistry + .listAvailable({ agent: input.agent }) + .map((manifest) => manifest.id); + const environment = Object.freeze({ ...(input.environment ?? {}) }); + const sourcePlan = parseSandboxMessagingPlan(input.sourcePlan, { + sandboxName: sourceSandboxName, + agent: input.agent, + supportedChannelIds, + environment, + }); + if (!sourcePlan) fail("source plan is invalid or uses a non-built-in channel"); + const sourceChannelIds = new Set(sourcePlan.channels.map((channel) => channel.channelId)); + if (sourcePlan.disabledChannels.some((channelId) => !sourceChannelIds.has(channelId))) { + fail("source plan disables a channel that is not configured"); + } + for (const channel of sourcePlan.channels) { + const manifest = manifestRegistry.get(channel.channelId); + if (!manifest || !manifest.supportedAgents.includes(input.agent)) { + fail(`source channel ${channel.channelId} is not a supported built-in`); + } + const listedDisabled = sourcePlan.disabledChannels.includes(channel.channelId); + if (channel.disabled !== listedDisabled || (channel.active && !channel.configured)) { + fail(`source channel ${channel.channelId} has inconsistent lifecycle state`); + } + const seenInputIds = new Set(); + for (const planInput of channel.inputs) { + if (seenInputIds.has(planInput.inputId)) { + fail(`source channel ${channel.channelId} repeats input ${planInput.inputId}`); + } + seenInputIds.add(planInput.inputId); + const manifestInput = manifest.inputs.find((candidate) => candidate.id === planInput.inputId); + if (!manifestInput || manifestInput.kind !== planInput.kind) { + fail( + `source channel ${channel.channelId} input ${planInput.inputId} is not manifest-owned`, + ); + } + if (planInput.kind === "secret" && planInput.value !== undefined) { + fail("source plan contains a raw secret input value"); + } + } + if (channel.active && !channel.disabled) { + for (const requiredInput of manifest.inputs.filter((candidate) => candidate.required)) { + const retained = channel.inputs.find((candidate) => candidate.inputId === requiredInput.id); + const available = + requiredInput.kind === "secret" + ? retained?.credentialAvailable === true + : retained?.value !== undefined; + if (!available) { + fail( + `active source channel ${channel.channelId} is missing required input ${requiredInput.id}`, + ); + } + } + } + } + + const compact = compactSandboxMessagingPlanForPersistence(sourcePlan); + const credentialBindings = (compact.credentialBindings ?? []).map( + ({ credentialHash: _sourceCredentialHash, ...binding }) => binding, + ); + const targetIntent = { + ...compact, + // A source hash describes the source gateway credential, not the explicit + // credential that clone provisioning will write into the destination. + credentialBindings, + sandboxName: destinationSandboxName, + // These fields are executable output, not retained clone intent. + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + stateUpdates: [], + healthChecks: [], + } as const; + const normalized = parseSandboxMessagingPlan(targetIntent, { + sandboxName: destinationSandboxName, + agent: input.agent, + supportedChannelIds, + environment, + }); + if (!normalized) fail("destination intent could not be normalized"); + const hydrated = hydrateDerivedSandboxMessagingPlanFields(normalized, { environment }); + const rebound = parseSandboxMessagingPlan(hydrated, { + sandboxName: destinationSandboxName, + agent: input.agent, + supportedChannelIds, + environment, + }); + if (!rebound) fail("destination plan could not be validated after manifest hydration"); + const secretProvenanceNeutralRebound = { + ...rebound, + credentialBindings: rebound.credentialBindings.map( + ({ credentialHash: _ambientCredentialHash, ...binding }) => binding, + ), + }; + for (const binding of secretProvenanceNeutralRebound.credentialBindings) { + const credential = manifestRegistry + .get(binding.channelId) + ?.credentials.find((candidate) => candidate.id === binding.credentialId); + const expectedProviderName = + credential === undefined + ? undefined + : resolveSandboxNameTemplate(credential.providerName, destinationSandboxName); + if (!expectedProviderName || binding.providerName !== expectedProviderName) { + fail(`destination provider identity for ${binding.channelId} could not be proven`); + } + } + return cloneAndDeepFreeze(secretProvenanceNeutralRebound); +} diff --git a/src/lib/messaging/compiler/engines/credential-binding-engine.ts b/src/lib/messaging/compiler/engines/credential-binding-engine.ts index 1a87227e597..11030a0cb28 100644 --- a/src/lib/messaging/compiler/engines/credential-binding-engine.ts +++ b/src/lib/messaging/compiler/engines/credential-binding-engine.ts @@ -1,19 +1,20 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { hashCredential } from "../../../security/credential-hash"; import type { ChannelManifest, SandboxMessagingCredentialBindingPlan, SandboxMessagingInputReference, } from "../../manifest"; import type { ManifestCompilerContext } from "../types"; -import { hashCredential } from "../../../security/credential-hash"; import { resolveSandboxNameTemplate } from "./template"; export function planCredentialBindings( manifest: ChannelManifest, context: ManifestCompilerContext, inputs: readonly SandboxMessagingInputReference[], + environment: Readonly> = process.env, ): SandboxMessagingCredentialBindingPlan[] { return manifest.credentials.map((credential) => { const sourceInput = inputs.find((input) => input.inputId === credential.sourceInput); @@ -24,7 +25,7 @@ export function planCredentialBindings( const envKey = sourceInput?.sourceEnv ?? credential.providerEnvKey; const credentialHash = credentialAvailable - ? (hashCredential(process.env[envKey]) ?? undefined) + ? (hashCredential(environment[envKey]) ?? undefined) : undefined; return { diff --git a/src/lib/messaging/compiler/engines/host-forward-engine.ts b/src/lib/messaging/compiler/engines/host-forward-engine.ts index 91bc8c796ff..3408c08bc66 100644 --- a/src/lib/messaging/compiler/engines/host-forward-engine.ts +++ b/src/lib/messaging/compiler/engines/host-forward-engine.ts @@ -17,10 +17,11 @@ export function planHostForward( inputs: readonly SandboxMessagingInputReference[], active: boolean, referenceResolver?: RenderTemplateReferenceResolver, + environment: Readonly> = process.env, ): SandboxMessagingHostForwardPlan | undefined { if (!active || !manifest.hostForward) return undefined; - const context = { inputs, env: process.env, referenceResolver }; + const context = { inputs, env: environment, referenceResolver }; if (!isTruthyRenderTemplate(manifest.hostForward.when, context)) return undefined; const portValue = resolveRenderTemplatesInValue(manifest.hostForward.port, context); diff --git a/src/lib/messaging/hydration.ts b/src/lib/messaging/hydration.ts index 605f1017f98..07d79f3dcfd 100644 --- a/src/lib/messaging/hydration.ts +++ b/src/lib/messaging/hydration.ts @@ -42,12 +42,19 @@ import { normalizePersistedInputs, } from "./persistence"; +export interface SandboxMessagingHydrationOptions { + /** Explicit environment seam for deterministic rehydration without ambient credentials. */ + readonly environment?: Readonly>; +} + export function hydrateDerivedSandboxMessagingPlanFields( plan: SandboxMessagingPlan, + options: SandboxMessagingHydrationOptions = {}, ): SandboxMessagingPlan { + const environment = options.environment ?? process.env; const manifestRegistry = createBuiltInChannelManifestRegistry(); const channels = plan.channels.map((channel) => - hydrateChannelFromManifest(plan, channel, manifestRegistry.get(channel.channelId)), + hydrateChannelFromManifest(plan, channel, manifestRegistry.get(channel.channelId), environment), ); const hydratedPlan = { ...plan, channels }; const manifests = channels.flatMap((channel) => { @@ -63,7 +70,7 @@ export function hydrateDerivedSandboxMessagingPlanFields( agentRender: plan.agentRender.length > 0 ? plan.agentRender - : agentRenderFromManifests(hydratedPlan, manifestRegistry), + : agentRenderFromManifests(hydratedPlan, manifestRegistry, environment), buildSteps: plan.buildSteps.length > 0 ? plan.buildSteps @@ -82,6 +89,7 @@ function hydrateChannelFromManifest( plan: SandboxMessagingPlan, channel: SandboxMessagingChannelPlan, manifest: ChannelManifest | undefined, + environment: Readonly>, ): SandboxMessagingChannelPlan { const { hostForward: _oldHostForward, ...channelWithoutHostForward } = channel; const disabled = channel.disabled || plan.disabledChannels.includes(channel.channelId); @@ -91,7 +99,7 @@ function hydrateChannelFromManifest( const configured = channel.configured; const active = channel.active && !disabled; const hostForward = manifest - ? planHostForward(manifest, inputs, active, createBuiltInRenderTemplateResolver()) + ? planHostForward(manifest, inputs, active, createBuiltInRenderTemplateResolver(), environment) : undefined; return { ...channelWithoutHostForward, @@ -231,6 +239,7 @@ function runtimeSetupHasEntries(setup: SandboxMessagingRuntimeSetupPlan | undefi function agentRenderFromManifests( plan: SandboxMessagingPlan, manifestRegistry: ReturnType, + environment: Readonly>, ): SandboxMessagingAgentRenderPlan[] { const render: SandboxMessagingAgentRenderPlan[] = []; const referenceResolver = createBuiltInRenderTemplateResolver(); @@ -239,7 +248,7 @@ function agentRenderFromManifests( if (!manifest) continue; const context = { inputs: channel.inputs, - env: process.env, + env: environment, referenceResolver, }; diff --git a/src/lib/messaging/index.ts b/src/lib/messaging/index.ts index 3ad95871443..1da55c0c870 100644 --- a/src/lib/messaging/index.ts +++ b/src/lib/messaging/index.ts @@ -3,6 +3,7 @@ export * from "./applier"; export * from "./channels"; +export * from "./clone-rebind"; export * from "./compiler"; export * from "./diagnostics"; export * from "./hooks"; diff --git a/src/lib/messaging/persistence.ts b/src/lib/messaging/persistence.ts index 1d07305ca4e..d189f480a6c 100644 --- a/src/lib/messaging/persistence.ts +++ b/src/lib/messaging/persistence.ts @@ -131,6 +131,7 @@ export function compactSandboxMessagingPlanForPersistence( export function normalizePersistedSandboxMessagingPlanShape( plan: MaybeCompactMessagingPlan, + environment: Readonly> = process.env, ): SandboxMessagingPlan { const manifestRegistry = createBuiltInChannelManifestRegistry(); const disabledChannels = plan.disabledChannels.filter( @@ -138,9 +139,19 @@ export function normalizePersistedSandboxMessagingPlanShape( ); const disabledSet = new Set(disabledChannels); const channels = plan.channels.map((channel) => - normalizePersistedChannel(channel, disabledSet, manifestRegistry.get(channel.channelId)), + normalizePersistedChannel( + channel, + disabledSet, + manifestRegistry.get(channel.channelId), + environment, + ), + ); + const credentialBindings = normalizePersistedCredentialBindings( + plan, + channels, + manifestRegistry, + environment, ); - const credentialBindings = normalizePersistedCredentialBindings(plan, channels, manifestRegistry); const normalizedPlan: SandboxMessagingPlan = { ...plan, channels, @@ -184,6 +195,7 @@ function normalizePersistedChannel( channel: MaybeCompactMessagingChannelPlan, disabledSet: ReadonlySet, manifest: ChannelManifest | undefined, + environment: Readonly>, ): SandboxMessagingChannelPlan { const disabled = channel.disabled ?? disabledSet.has(channel.channelId); const configured = channel.configured ?? true; @@ -194,7 +206,13 @@ function normalizePersistedChannel( const active = channel.active ?? (configured && !disabled && requiredInputsAvailable(manifest, inputs)); const hostForward = manifest - ? planHostForward(manifest, inputs, active && !disabled, createBuiltInRenderTemplateResolver()) + ? planHostForward( + manifest, + inputs, + active && !disabled, + createBuiltInRenderTemplateResolver(), + environment, + ) : undefined; return { @@ -308,6 +326,7 @@ function normalizePersistedCredentialBindings( plan: MaybeCompactMessagingPlan, channels: readonly SandboxMessagingChannelPlan[], manifestRegistry: ReturnType, + environment: Readonly>, ): SandboxMessagingCredentialBindingPlan[] { const persisted = plan.credentialBindings ?? []; if ( @@ -337,6 +356,7 @@ function normalizePersistedCredentialBindings( planForBindings, manifests, new Map(channels.map((channel) => [channel.channelId, channel.inputs] as const)), + environment, ); return generated.map((binding) => overlayPersistedCredentialBinding(binding, persisted)); } @@ -345,12 +365,16 @@ function credentialBindingsFromManifests( plan: SandboxMessagingPlan, manifests: readonly ChannelManifest[], inputRegistry: ReadonlyMap, + environment: Readonly>, ): SandboxMessagingCredentialBindingPlan[] { const context = compilerContext(plan); return manifests.flatMap((manifest) => - planCredentialBindings(manifest, context, inputRegistry.get(manifest.id) ?? []).map((binding) => - overlayPersistedCredentialBinding(binding, plan.credentialBindings), - ), + planCredentialBindings( + manifest, + context, + inputRegistry.get(manifest.id) ?? [], + environment, + ).map((binding) => overlayPersistedCredentialBinding(binding, plan.credentialBindings)), ); } diff --git a/src/lib/messaging/plan-validation.test.ts b/src/lib/messaging/plan-validation.test.ts index 3879b920dea..f64bb7e932f 100644 --- a/src/lib/messaging/plan-validation.test.ts +++ b/src/lib/messaging/plan-validation.test.ts @@ -272,6 +272,20 @@ describe("parseSandboxMessagingPlan", () => { ).toBeNull(); }); + it.each([ + ["a disabled flag missing from disabledChannels", true, []], + ["disabledChannels membership with an enabled flag", false, ["telegram"]], + ] as const)("rejects %s", (_label, disabled, disabledChannels) => { + expect( + parseSandboxMessagingPlan( + makePlan({ + channels: [{ ...makePlan().channels[0], disabled }], + disabledChannels: [...disabledChannels], + }), + ), + ).toBeNull(); + }); + it.each([ ["disabledChannels", { disabledChannels: [" Telegram "] }], ["credentialBindings", { credentialBindings: [{ channelId: "Telegram" }] }], diff --git a/src/lib/messaging/plan-validation.ts b/src/lib/messaging/plan-validation.ts index 422d0a61ae3..f4a2a91e4ba 100644 --- a/src/lib/messaging/plan-validation.ts +++ b/src/lib/messaging/plan-validation.ts @@ -19,6 +19,8 @@ export interface SandboxMessagingPlanParseOptions { sandboxName?: string | null; agent?: MessagingAgentId | string | null; supportedChannelIds?: readonly MessagingChannelId[] | readonly string[] | null; + /** Explicit environment seam for deterministic rehydration without ambient credentials. */ + environment?: Readonly>; } export function parseSandboxMessagingPlan( @@ -101,6 +103,18 @@ export function parseSandboxMessagingPlan( normalizedChannelIds.add(normalizedChannelId); } if (!value.disabledChannels.every(isCanonicalMessagingChannelId)) return null; + const disabledChannelIds = new Set(value.disabledChannels as string[]); + if ( + disabledChannelIds.size !== value.disabledChannels.length || + [...disabledChannelIds].some((channelId) => !normalizedChannelIds.has(channelId)) || + value.channels.some( + (channel) => + isObjectRecord(channel) && + (channel.disabled === true) !== disabledChannelIds.has(String(channel.channelId)), + ) + ) { + return null; + } if ( !hasCanonicalChannelReferences(value.credentialBindings) || !hasCanonicalChannelReferences(value.agentRender) || @@ -114,7 +128,10 @@ export function parseSandboxMessagingPlan( } return cloneSandboxMessagingPlan( - normalizePersistedSandboxMessagingPlanShape(value as MaybeCompactMessagingPlan), + normalizePersistedSandboxMessagingPlanShape( + value as MaybeCompactMessagingPlan, + options.environment, + ), ); } diff --git a/src/lib/name-validation.ts b/src/lib/name-validation.ts index a3b49850278..5af96f5bc83 100644 --- a/src/lib/name-validation.ts +++ b/src/lib/name-validation.ts @@ -5,6 +5,11 @@ import { NAME_ALLOWED_FORMAT as CANONICAL_NAME_ALLOWED_FORMAT, NAME_MAX_LENGTH as CANONICAL_NAME_MAX_LENGTH, NAME_VALID_PATTERN as CANONICAL_NAME_VALID_PATTERN, + PROVIDER_NAME_ALLOWED_FORMAT as CANONICAL_PROVIDER_NAME_ALLOWED_FORMAT, + PROVIDER_NAME_MAX_LENGTH as CANONICAL_PROVIDER_NAME_MAX_LENGTH, + PROVIDER_NAME_VALID_PATTERN as CANONICAL_PROVIDER_NAME_VALID_PATTERN, + isValidName as isCanonicalValidName, + isValidProviderName as isCanonicalValidProviderName, } from "../../nemoclaw/dist/shared/sandbox-name.cjs"; // sourceOfTruth: nemoclaw/src/shared/sandbox-name.cts @@ -15,6 +20,11 @@ import { export const NAME_MAX_LENGTH = CANONICAL_NAME_MAX_LENGTH; export const NAME_ALLOWED_FORMAT = CANONICAL_NAME_ALLOWED_FORMAT; export const NAME_VALID_PATTERN = CANONICAL_NAME_VALID_PATTERN; +export const PROVIDER_NAME_MAX_LENGTH = CANONICAL_PROVIDER_NAME_MAX_LENGTH; +export const PROVIDER_NAME_ALLOWED_FORMAT = CANONICAL_PROVIDER_NAME_ALLOWED_FORMAT; +export const PROVIDER_NAME_VALID_PATTERN = CANONICAL_PROVIDER_NAME_VALID_PATTERN; +export const isValidName = isCanonicalValidName; +export const isValidProviderName = isCanonicalValidProviderName; function validationSubject(label: string): string { const normalized = label.trim().toLowerCase(); diff --git a/src/lib/onboard/channel-state.test.ts b/src/lib/onboard/channel-state.test.ts index f32e6efcb30..aaffa4f373f 100644 --- a/src/lib/onboard/channel-state.test.ts +++ b/src/lib/onboard/channel-state.test.ts @@ -18,7 +18,17 @@ function sessionWithPlan( sandboxName, agent: "openclaw", workflow: "onboard", - channels: [], + channels: disabledChannels.map((channelId) => ({ + channelId, + displayName: channelId, + authMode: "none", + active: false, + selected: false, + configured: false, + disabled: true, + inputs: [], + hooks: [], + })), disabledChannels, credentialBindings: [], networkPolicy: { presets: [], entries: [] }, diff --git a/src/lib/onboard/inference-route.ts b/src/lib/onboard/inference-route.ts index 810f2464c5c..55b78b08d22 100644 --- a/src/lib/onboard/inference-route.ts +++ b/src/lib/onboard/inference-route.ts @@ -1,7 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { parseGatewayInference } from "../inference/config"; +import { + getSandboxInferenceConfig, + parseGatewayInference, + resolveAgentInferenceApi, +} from "../inference/config"; import { type CurrentGatewayRouteCompatibilityCheck, type CurrentGatewayRouteDiscoveryPreflight, @@ -12,6 +16,20 @@ import { listSandboxes } from "../state/registry"; type RunCaptureOpenshell = (args: string[], options?: { ignoreError?: boolean }) => string | null; +/** Resolve the exact portable inference route used by managed clone preparation. */ +export function resolveManagedStartupInferenceRoute( + agentName: string, + provider: string, + model: string, + preferredInferenceApi: string | null, +) { + const api = + agentName === "langchain-deepagents-code" + ? "openai-completions" + : resolveAgentInferenceApi(agentName, provider, preferredInferenceApi); + return getSandboxInferenceConfig(model, provider, api); +} + export function createInferenceRouteHelpers( runCaptureOpenshell: RunCaptureOpenshell, listSandboxesFn: typeof listSandboxes = listSandboxes, diff --git a/src/lib/onboard/lifecycle-contracts.md b/src/lib/onboard/lifecycle-contracts.md index 36ce0c7c201..38a3b2e3d98 100644 --- a/src/lib/onboard/lifecycle-contracts.md +++ b/src/lib/onboard/lifecycle-contracts.md @@ -117,6 +117,7 @@ runtime mutation | **`--fresh` onboard** — `resolveOnboardEntryOptions`, `prepareFreshSession`, `createBaseImageResolutionContext` | Current flags/environment/prompts replace resumable intent. `--fresh` disables auto-resume and forces base-image resolution; it does not prove that the selected sandbox name is unused. | The first destructive effect is local: the prior onboard session is cleared before a new session is saved. A matching live sandbox can later reuse or recreate through the normal sandbox decision; `--fresh` does not itself delete it. | The new session and machine snapshot replace the old resume checkpoint. Credential and effect boundaries then match new onboard or live recreate. | The discarded resume checkpoint is not restored on later failure. Covered by `entry-options.test.ts`, `session-bootstrap.test.ts`, and base-image resolution tests. | | **Resume, re-onboard, or recreate** — `onboard()`, `prepareOnboardSession`, `decideSandboxResume`, live-sandbox handling in `createSandbox` | For `--resume`, the recorded session is authoritative and conflicting current name/provider/model/image/tool-disclosure hints are rejected. A new re-onboard run takes current flags, environment, and prompts as intent while registry/gateway state provides drift evidence. The machine resolves a complete secret-free create intent, including policy, messaging/provider, GPU, resource, disabled-channel, and agent inputs, before repair/removal or live recreation. | Ordinary live recreation conditionally backs up before provider cleanup, **delete**, image removal, and registry removal. A selected pre-upgrade backup suppresses a new one; an explicit override permits recreation without backup. Resume registry removal and `repair-and-recreate` now follow complete intent validation. Temporary policy/build artifacts remain materialization effects after the delete boundary. | Resume continues the recorded session/machine snapshot; non-resume re-onboard writes a new session first. OpenClaw records completed sandbox name, web search, messaging, and resource choices with explicit progress markers, including explicit `null` choices, while the complete create intent stays process-local and is not persisted or emitted. Raw credential values remain outside the session. A missing process value can be rebound only when the same OpenClaw session recorded successfully registering that provider and its live name, type, and credential-key binding still match; otherwise interactive resume requests it again and non-interactive resume exits with environment-variable guidance. Credentials are checked before mutation and again immediately before materialization. | Restore failures warn and can still publish the replacement; managed-DCode live-selection failure leaves a running, unregistered sandbox with manual-delete guidance. Checkpoint replay reuses an exact live sandbox after an interrupted create and backfills missing create/register receipts. Cancel rollback is not armed and there is no rebuild-style receipt rollback. Coverage: transition traces, create-intent characterization, checkpoint replay and resume guards, and sandbox-handler crash recovery. Gaps: early backup asymmetry and no rebuild-style cross-effect rollback. | | **Rebuild or installer-driven upgrade** — `rebuildSandbox` in `rebuild-pipeline.ts`; `upgradeSandboxes` | Registry state is authoritative. A matching session may fill guarded legacy gaps only when its selection agrees; an unrelated/global session is never used. Ambient provider/model selection is quarantined by `isolateAmbientRecreateEnv`, apart from narrowly scoped legacy recovery. Preflight assembles target config, messaging/policy/runtime inputs, recovery inputs, and a retained replacement context. Generic agents use `preflightRebuildImage`; DCode uses its specialized managed-context preflight instead and proves the live route only for normal live rebuild. Resource profile is not part of preflight. | Consent persistence, target-gateway selection/recovery, and target-preflight registry updates can precede disposable image build/probes. Backup is the first durable recovery checkpoint when available. Shields unlock, MCP detach/scrub, and NIM stop are destructive in-place effects before the **sandbox delete** boundary. Prepared context and mutation-edge conditions are rechecked before delete, proving buildability/input identity but not replacement health or atomic swap. | Durable checkpoints are the backup/recovery manifest when one exists and the rewritten recreate session; stale recovery can reach deletion without a manifest, making that session its first new durable checkpoint. Rollback receipts/snapshots are process-local. Credential metadata comes from the target or guarded fallback; raw credentials/providers are checked against current process/gateway state, while prepared installer recovery may reconstruct a missing gateway provider from a validated host credential. | In-process rollback best-effort restores registry/MCP retry metadata, but process death after non-MCP delete can still lose it. The inner onboard selects resource profile after deletion from non-quarantined ambient input. Covered by rebuild, image-preflight, DCode, and messaging tests. Gaps: post-delete resource intent plus health-before-delete and atomic swap. Closed issue #5801 records the original gap; #6835 fixed only the printed recovery path. | +| **Managed snapshot clone handoff (internal and dormant)** — `prepareManagedWorkloadCloneHandoff` | The current source registry row owns mutable operator intent; the selected snapshot owns immutable managed-workload and provider-runtime history. Preparation proves the selected runtime provider and its `clone` capability, exact current registry generation and live-identity fingerprint, snapshot/source workload equivalence, snapshot runtime generation, and the state layer's selected-manifest/payload digest. It then rebinds the secret-free startup profile, messaging intent, dashboard identity, and any provider-owned Hermes inference name for OpenClaw, Hermes, or DCode without a central Podman-specific switch. | None. The handoff is an inert planning artifact and performs no provider, sandbox, registry, filesystem, credential, or broker effect. Production snapshot restore continues to reject cross-sandbox managed-image restore through `rejectManagedSnapshotCloneUntilRebind`; no user-visible clone support is advertised by this slice. | The returned handoff is a deeply frozen, locally owned value carrying exact source registry compare-and-swap authority, immutable workload authority, provider runtime generation evidence, `SnapshotRestoreAuthority` content identity, rebound managed profile, and destination registry intent. It contains credential-presence metadata and provider names, never raw credential values or live handles. | There is intentionally no compensation because preparation has no effects. `src/lib/onboard/managed-workload-clone-handoff.test.ts`, `src/lib/onboard/managed-startup-clone-rebinder.test.ts`, and `src/lib/actions/sandbox/snapshot-managed-clone-handoff-dormancy.test.ts` cover the all-agent, Docker/MXC-style provider, canonical-name, and fail-closed boundaries. Provider materialization, destination creation/bootstrap, mutation-edge content/provider authority revalidation, rollback, recovery, protected E2E, and activation remain tracked by [#7744](https://github.com/NVIDIA/NemoClaw/issues/7744). | | **Channel add/remove/start/stop** — `addSandboxChannel`, `removeSandboxChannel`, `sandboxChannelsSetEnabled` in `policy-channel.ts` | Add compiles and merges a manifest-derived channel delta with `MessagingWorkflowPlanner`. Start, stop, and remove transform the registry plan and rehydrate executable render/build/runtime/forward details from current manifests. | Token-backed add can mutate gateway credentials before policy and plan persistence; QR/in-sandbox-auth add skips that credential upsert. Start persists the enabled plan before policy; stop persists the disabled plan before the rebuild prompt. Remove clears QR-backed durable state when applicable, detaches gateway/bridge state, removes policy, then persists the plan. A queued rebuild has a separate delete boundary. | The compact registry messaging plan is authoritative; render/build/runtime/state/health entries and nested host-forward details are rehydrated rather than persisted. Session policy-preset sync is best-effort, and channel mutations do not rewrite `Session.messagingPlan`. Raw tokens stay in process/gateway bindings. | `rollbackChannelAdd`, re-disable after failed start, and fail-closed QR-state cleanup provide partial compensation. Covered by `policy-channel*.test.ts`, `workflow-planner.test.ts`, and channel integration tests. Gaps: channel add has a separate `--force` conflict policy; add/remove effects can precede plan persistence, and persistence failures are not fully rolled back. | | **Provider, model, or credential-binding change** — `runInferenceSet` | CLI intent plus registry/session metadata. Target resolution and OpenShell preparation occur before locking. The target is re-resolved in the mutating phase under the sandbox lifecycle and timer-bound shields locks; that phase validates provider/model syntax, selected agent, shields state, and local reachability before the first write. | First mutation is the gateway route, then a minimal registry write, API-family/config resolution, registry refresh, best-effort config/hash sync, matching-session update, and audit. An OpenClaw API-family change can then restart the managed gateway after the shields lock is released but while the outer sandbox lock remains held. No sandbox deletion. | Registry and matching session store logical provider/model/credential-environment metadata. Audit records the action, sandbox, and reason rather than credentials; raw values remain gateway-bound. | Forward-only; no rollback. `rebuild` is the repair path for degraded state. Covered by `inference-set*.test.ts`. Gap: several stores can diverge after a mid-sequence failure. | | **Credential rotation** — `configRotateToken` in `src/lib/sandbox/config.ts` | A session with `credentialEnv` selects the provider and binding. A non-null different `sandboxName` is rejected, but a legacy/null session name is accepted for the requested sandbox. The new value comes from a named environment variable, stdin, or a secret prompt; it is trimmed, then rejected when empty or still containing internal whitespace. | `saveCredential` first stages the value in the current process. OpenShell provider update is the first external mutation, with provider create as a fallback; audit follows. No sandbox deletion. | The logical binding is unchanged, so session and registry are not rewritten. The raw value exists only in process memory/environment and the gateway provider; audit records action/sandbox/reason without the value. | No rollback after a successful provider update; an audit failure can report failure after the credential is already active. Covered by the rotate-token cases in `test/config-set-nested-ssrf.test.ts`. Gap: a null-name legacy session is not strongly bound to the requested sandbox. | @@ -243,6 +244,7 @@ PR #5955 moved the rebuild messaging conflict check before destruction. | Session sanitation, sandbox prompt checkpoints, and no-secret persistence | `src/lib/state/onboard-session-sandbox-prompts.test.ts`, `src/lib/state/onboard-checkpoint.test.ts`, `machine/handlers/sandbox-create-intent-boundary.test.ts` | Tri-state decisions remain scoped to checkpointed sandbox choices. | | Versioned checkpoint schema, tri-state decisions, migration, and unknown-future fail-safe | `src/lib/state/onboard-checkpoint.test.ts`, `src/lib/state/onboard-checkpoint-migrate.test.ts` | Live decision reads still use legacy fields | | Resumable create replay, durable identity, and stale-binding fail-closed | `src/lib/onboard/checkpoint-replay.test.ts`, `src/lib/onboard/checkpoint-resume-guard.test.ts`, `machine/handlers/sandbox-checkpoint-crash-recovery.test.ts` | None at the sandbox-handler boundary. | -| Managed snapshot workload, content, and provider authority across explicit and rebuild flows | `src/lib/actions/sandbox/snapshot/backup-authority.test.ts`, `restore-authority.test.ts`, `managed-profile.test.ts`, `provider-lifecycle.test.ts`, and `snapshot-managed-provider-restore-order.test.ts` | Cross-provider clone and rebind, durable interrupted-restore recovery, and user-visible runtime activation remain separate review units. | +| Managed snapshot workload, content, and provider authority across explicit and rebuild flows | `src/lib/actions/sandbox/snapshot/backup-authority.test.ts`, `restore-authority.test.ts`, `managed-profile.test.ts`, `provider-lifecycle.test.ts`, and `snapshot-managed-provider-restore-order.test.ts` | Durable interrupted-restore recovery and user-visible runtime activation remain separate review units. | +| Dormant managed clone handoff and fail-closed production boundary | `src/lib/onboard/managed-workload-clone-handoff.test.ts`, `src/lib/onboard/managed-startup-clone-rebinder.test.ts`, and `src/lib/actions/sandbox/snapshot-managed-clone-handoff-dormancy.test.ts` | Provider materialization, destination bootstrap, rollback, recovery, protected E2E, and activation remain tracked by [#7744](https://github.com/NVIDIA/NemoClaw/issues/7744). | When lifecycle behavior changes one of these contracts, update the map and the narrow owning test in that same PR. Do not add source-text scans or production scaffolding solely to preserve current orchestration order. diff --git a/src/lib/onboard/managed-startup-clone-rebinder.test.ts b/src/lib/onboard/managed-startup-clone-rebinder.test.ts new file mode 100644 index 00000000000..2cc15bfc1e9 --- /dev/null +++ b/src/lib/onboard/managed-startup-clone-rebinder.test.ts @@ -0,0 +1,430 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import type { SandboxMessagingPlan } from "../messaging/manifest"; +import { PEM } from "./__test-helpers__/corporate-ca-fixtures"; +import { + type ManagedStartupCloneCurrentState, + ManagedStartupCloneRebindError, + rebindManagedStartupProfileForClone, +} from "./managed-startup/clone-rebinder"; +import { + buildManagedStartupProfile, + type ManagedStartupProfileBuilderInput, +} from "./managed-startup/profile-builder"; + +function messagingPlan(agent: "openclaw" | "hermes", sandboxName = "source"): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName, + agent, + workflow: "onboard", + channels: [ + { + channelId: "telegram", + displayName: "Telegram", + authMode: "token-paste", + configured: true, + active: true, + selected: true, + disabled: false, + inputs: [ + { + channelId: "telegram", + inputId: "botToken", + kind: "secret", + required: true, + sourceEnv: "TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + }, + { + channelId: "telegram", + inputId: "allowedIds", + kind: "config", + required: false, + statePath: "allowedIds.telegram", + value: ["123456"], + }, + ], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + stateUpdates: [], + healthChecks: [], + }; +} + +function openClawInput(): ManagedStartupProfileBuilderInput { + return { + agent: "openclaw", + inference: { + routeProvider: "inference", + upstreamProvider: "openai-api", + model: "gpt-5.4", + routedBaseUrl: "https://inference.local/v1", + upstreamEndpointUrl: null, + api: "openai-responses", + primaryModelRef: "inference/gpt-5.4", + compatibility: {}, + }, + dashboard: { + agent: "openclaw", + mode: "loopback", + url: "http://127.0.0.1:18789", + port: 18_789, + bindAddress: "127.0.0.1", + wslExposure: false, + }, + webSearch: null, + toolDisclosure: "progressive", + hermesToolGateways: [], + messagingPlan: messagingPlan("openclaw"), + dcodeAutoApprovalMode: null, + observabilityEnabled: null, + environment: {}, + corporateCa: null, + }; +} + +function hermesInput(): ManagedStartupProfileBuilderInput { + return { + agent: "hermes", + inference: { + routeProvider: "inference", + upstreamProvider: "compatible-anthropic-endpoint", + model: "claude-sonnet-4-6", + routedBaseUrl: "https://inference.local/v1", + upstreamEndpointUrl: null, + api: "openai-completions", + primaryModelRef: null, + compatibility: null, + }, + dashboard: { + agent: "hermes", + mode: "loopback-forwarded", + url: "http://127.0.0.1:19189", + publicPort: 19_189, + internalPort: 29_189, + tuiEnabled: true, + }, + webSearch: null, + toolDisclosure: "progressive", + hermesToolGateways: [], + messagingPlan: messagingPlan("hermes"), + dcodeAutoApprovalMode: null, + observabilityEnabled: null, + environment: {}, + corporateCa: null, + }; +} + +function dcodeInput(): ManagedStartupProfileBuilderInput { + return { + agent: "langchain-deepagents-code", + inference: { + routeProvider: "inference", + upstreamProvider: "openrouter", + model: "openai/gpt-5.4", + routedBaseUrl: "https://inference.local/v1", + upstreamEndpointUrl: "https://openrouter.ai/api/v1", + api: "openai-completions", + primaryModelRef: null, + compatibility: null, + }, + dashboard: { agent: "langchain-deepagents-code", mode: "disabled" }, + webSearch: null, + toolDisclosure: "progressive", + hermesToolGateways: [], + messagingPlan: null, + dcodeAutoApprovalMode: "disabled", + observabilityEnabled: false, + environment: {}, + corporateCa: null, + }; +} + +function rebind( + built: ReturnType, + expectedAgent: ManagedStartupProfileBuilderInput["agent"], + destinationDashboardPort: number | null, + currentOverrides: Partial = {}, + names: { + readonly sourceSandboxName?: string; + readonly destinationSandboxName?: string; + } = {}, +) { + const profile = built.profile; + const webSearch = + profile.agentConfig.agent === "langchain-deepagents-code" + ? null + : profile.agentConfig.webSearch; + const hermesDashboard = profile.dashboard.agent === "hermes" ? profile.dashboard : null; + const dcodeConfig = + profile.agentConfig.agent === "langchain-deepagents-code" ? profile.agentConfig : null; + return rebindManagedStartupProfileForClone({ + sourceSandboxName: names.sourceSandboxName ?? "source", + destinationSandboxName: names.destinationSandboxName ?? "destination", + expectedAgent, + destinationDashboardPort, + ...(expectedAgent === "hermes" && profile.tools.enabledGateways.length > 0 + ? { destinationHermesInferenceProvider: "destination-hermes-inference" } + : {}), + encodedProfile: built.encodedProfile, + startupProfileSha256: built.startupProfileSha256, + ...(built.corporateCaB64 === undefined ? {} : { corporateCaB64: built.corporateCaB64 }), + currentSource: { + provider: profile.inference.upstreamProvider, + model: profile.inference.model, + endpointUrl: profile.inference.upstreamEndpointUrl, + preferredInferenceApi: profile.inference.api, + compatibleEndpointReasoning: + profile.agent === "openclaw" && profile.inference.upstreamProvider === "compatible-endpoint" + ? profile.tuning.reasoning + ? "true" + : "false" + : null, + compatibleEndpointReasoningEffort: + profile.agent === "openclaw" && + profile.inference.upstreamProvider === "compatible-endpoint" && + profile.tuning.reasoningEffort !== "default" + ? profile.tuning.reasoningEffort + : null, + toolDisclosure: profile.tools.disclosure, + webSearchEnabled: webSearch?.enabled, + webSearchProvider: webSearch?.provider, + messaging: + profile.messaging.plan === null + ? undefined + : { schemaVersion: 1, plan: profile.messaging.plan }, + hermesToolGateways: profile.tools.enabledGateways, + hermesDashboardEnabled: hermesDashboard?.mode === "loopback-forwarded", + hermesDashboardPort: + hermesDashboard?.mode === "loopback-forwarded" ? hermesDashboard.publicPort : undefined, + hermesDashboardInternalPort: + hermesDashboard?.mode === "loopback-forwarded" ? hermesDashboard.internalPort : undefined, + hermesDashboardTui: + hermesDashboard?.mode === "loopback-forwarded" ? hermesDashboard.tuiEnabled : undefined, + dashboardPort: + profile.dashboard.agent === "openclaw" + ? profile.dashboard.port + : hermesDashboard?.mode === "loopback-forwarded" + ? hermesDashboard.publicPort + : undefined, + dashboardRemoteBindPrepared: + profile.dashboard.agent === "openclaw" + ? profile.dashboard.bindAddress === "0.0.0.0" + : undefined, + dcodeAutoApprovalMode: dcodeConfig?.autoApprovalMode, + observabilityEnabled: dcodeConfig?.observabilityEnabled, + ...currentOverrides, + }, + }); +} + +describe("rebindManagedStartupProfileForClone", () => { + it("rebinds OpenClaw dashboard and manifest-derived provider identity without ambient tokens", () => { + const built = buildManagedStartupProfile(openClawInput()); + vi.stubEnv("TELEGRAM_BOT_TOKEN", "ambient-token-must-not-be-read"); + const rebound = rebind(built, "openclaw", 20_789); + expect(rebound.profile.dashboard).toMatchObject({ + agent: "openclaw", + url: "http://127.0.0.1:20789", + port: 20_789, + }); + expect(rebound.profile.messaging.plan).toMatchObject({ + sandboxName: "destination", + credentialBindings: [ + { + providerName: "destination-telegram-bridge", + credentialAvailable: true, + }, + ], + }); + expect(JSON.stringify(rebound.profile.messaging.plan)).not.toContain( + "ambient-token-must-not-be-read", + ); + expect( + (rebound.profile.messaging.plan as unknown as SandboxMessagingPlan).credentialBindings[0], + ).not.toHaveProperty("credentialHash"); + expect(rebound.startupProfileSha256).not.toBe(built.startupProfileSha256); + expect(Object.isFrozen(rebound)).toBe(true); + expect(Object.isFrozen(rebound.profile)).toBe(true); + expect(Object.isFrozen(rebound.profile.messaging.plan)).toBe(true); + expect(Object.isFrozen(rebound.profile.tools.enabledGateways)).toBe(true); + }); + + it("rebinds the current compatible-endpoint reasoning effort instead of stale receipt tuning", () => { + const built = buildManagedStartupProfile({ + ...openClawInput(), + inference: { + ...openClawInput().inference, + upstreamProvider: "compatible-endpoint", + api: "openai-completions", + }, + environment: { + NEMOCLAW_REASONING: "true", + NEMOCLAW_REASONING_EFFORT: "low", + }, + }); + + const rebound = rebind(built, "openclaw", 20_789, { + compatibleEndpointReasoning: "true", + compatibleEndpointReasoningEffort: "high", + }); + + expect(built.profile.tuning.reasoningEffort).toBe("low"); + expect(rebound.profile.tuning).toMatchObject({ + reasoning: true, + reasoningEffort: "high", + }); + }); + + it("rebinds Hermes public dashboard and provider identity while retaining its internal port", () => { + const rebound = rebind(buildManagedStartupProfile(hermesInput()), "hermes", 21_189); + + expect(rebound.profile.dashboard).toEqual({ + agent: "hermes", + mode: "loopback-forwarded", + url: "http://127.0.0.1:21189", + publicPort: 21_189, + internalPort: 29_189, + tuiEnabled: true, + }); + expect(rebound.profile.messaging.plan).toMatchObject({ + sandboxName: "destination", + credentialBindings: [{ providerName: "destination-telegram-bridge" }], + }); + }); + + it("rebinds managed-tool Hermes inference to the destination provider identity", () => { + const built = buildManagedStartupProfile({ + ...hermesInput(), + hermesToolGateways: ["nous-web"], + }); + + const rebound = rebind(built, "hermes", 21_189); + + expect(rebound.profile.inference).toMatchObject({ + routeProvider: "inference", + upstreamProvider: "destination-hermes-inference", + }); + expect(rebound.profile.tools.enabledGateways).toEqual(["nous-web"]); + }); + + it("rebinds DCode without inventing dashboard or messaging state", () => { + const rebound = rebind( + buildManagedStartupProfile(dcodeInput()), + "langchain-deepagents-code", + null, + ); + + expect(rebound.profile.dashboard).toEqual({ + agent: "langchain-deepagents-code", + mode: "disabled", + }); + expect(rebound.profile.messaging).toEqual({ plan: null }); + expect(rebound.encodedProfile).toBe(buildManagedStartupProfile(dcodeInput()).encodedProfile); + }); + + it("retains and revalidates the exact public corporate CA transport", () => { + const built = buildManagedStartupProfile({ + ...openClawInput(), + corporateCa: { + pem: PEM, + sourcePath: "/public/corporate-ca.pem", + sourceEnv: "NEMOCLAW_CORPORATE_CA_BUNDLE", + }, + }); + + const rebound = rebind(built, "openclaw", 20_789); + + expect(rebound.corporateCaB64).toBe(built.corporateCaB64); + expect(rebound.profile.corporateCa).toEqual(built.profile.corporateCa); + }); + + it("fails closed on receipt hash, source messaging identity, and unexpected CA transport", () => { + const built = buildManagedStartupProfile(openClawInput()); + expect(() => + rebindManagedStartupProfileForClone({ + sourceSandboxName: "source", + destinationSandboxName: "destination", + expectedAgent: "openclaw", + destinationDashboardPort: 20_789, + encodedProfile: built.encodedProfile, + startupProfileSha256: "0".repeat(64), + currentSource: { + provider: built.profile.inference.upstreamProvider, + model: built.profile.inference.model, + }, + }), + ).toThrow(ManagedStartupCloneRebindError); + + const wrongIdentity = buildManagedStartupProfile({ + ...openClawInput(), + messagingPlan: messagingPlan("openclaw", "other-source"), + }); + expect(() => rebind(wrongIdentity, "openclaw", 20_789)).toThrow(/source plan is invalid/u); + + expect(() => + rebindManagedStartupProfileForClone({ + sourceSandboxName: "source", + destinationSandboxName: "destination", + expectedAgent: "openclaw", + destinationDashboardPort: 20_789, + encodedProfile: built.encodedProfile, + startupProfileSha256: built.startupProfileSha256, + corporateCaB64: "eA==", + currentSource: { + provider: built.profile.inference.upstreamProvider, + model: built.profile.inference.model, + }, + }), + ).toThrow(/corporate CA transport/u); + }); + + it("uses the canonical sandbox grammar and rejects an identity-preserving clone", () => { + const built = buildManagedStartupProfile(dcodeInput()); + + expect(() => + rebind( + built, + "langchain-deepagents-code", + null, + {}, + { + sourceSandboxName: "1source", + }, + ), + ).toThrow(/source sandbox name is invalid/u); + expect(() => + rebind( + built, + "langchain-deepagents-code", + null, + {}, + { + destinationSandboxName: "1destination", + }, + ), + ).toThrow(/destination sandbox name is invalid/u); + expect(() => + rebind( + built, + "langchain-deepagents-code", + null, + {}, + { + destinationSandboxName: "source", + }, + ), + ).toThrow(/source and destination sandbox names must differ/u); + }); +}); diff --git a/src/lib/onboard/managed-startup/clone-rebinder.ts b/src/lib/onboard/managed-startup/clone-rebinder.ts new file mode 100644 index 00000000000..ff2c6d3eb35 --- /dev/null +++ b/src/lib/onboard/managed-startup/clone-rebinder.ts @@ -0,0 +1,496 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; + +import { cloneAndDeepFreeze } from "../../core/immutable"; +import { resolveContextWindowForModel } from "../../inference/context-window"; +import { rebindSandboxMessagingPlanForClone } from "../../messaging/clone-rebind"; +import { isValidName } from "../../name-validation"; +import { DEFAULT_TOOL_DISCLOSURE } from "../../tool-disclosure"; +import { resolveManagedStartupInferenceRoute } from "../inference-route"; +import { validateManagedStartupCorporateCaTransport } from "./application"; +import { + decodeManagedStartupProfile, + encodeManagedStartupProfile, + type ManagedStartupAgent, + type ManagedStartupDashboard, + type ManagedStartupJsonObject, + type ManagedStartupProfile, + validateManagedStartupProfile, +} from "./profile"; + +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const MANAGED_INFERENCE_API_SET = new Set([ + "openai-completions", + "openai-responses", + "anthropic-messages", +]); + +/** + * Current durable state for the source sandbox. The managed-image receipt owns + * immutable image affordances; mutable operator intent is re-read from this + * state before a clone handoff can be prepared. + */ +export interface ManagedStartupCloneCurrentState { + readonly provider?: string | null; + readonly model?: string | null; + readonly endpointUrl?: string | null; + readonly preferredInferenceApi?: string | null; + readonly compatibleEndpointReasoning?: "true" | "false" | string | null; + readonly compatibleEndpointReasoningEffort?: "low" | "medium" | "high" | string | null; + readonly toolDisclosure?: "progressive" | "direct" | string; + readonly webSearchEnabled?: boolean; + readonly webSearchProvider?: "brave" | "tavily" | string | null; + readonly messaging?: { readonly schemaVersion?: number; readonly plan?: unknown } | null; + readonly hermesToolGateways?: readonly string[]; + readonly hermesDashboardEnabled?: boolean; + readonly hermesDashboardPort?: number | null; + readonly hermesDashboardInternalPort?: number | null; + readonly hermesDashboardTui?: boolean; + readonly dashboardPort?: number | null; + readonly dashboardRemoteBindPrepared?: boolean; + readonly dcodeAutoApprovalMode?: "disabled" | "thread-opt-in" | string; + readonly observabilityEnabled?: boolean; +} + +export interface ManagedStartupCloneRebindInput { + readonly sourceSandboxName: string; + readonly destinationSandboxName: string; + readonly expectedAgent: ManagedStartupAgent; + readonly destinationDashboardPort: number | null; + /** Destination-scoped OpenShell identity for Hermes' host-minted inference key. */ + readonly destinationHermesInferenceProvider?: string; + readonly encodedProfile: string; + readonly startupProfileSha256: string; + readonly corporateCaB64?: string; + readonly currentSource: ManagedStartupCloneCurrentState; +} + +export interface ReboundManagedStartupClone { + readonly profile: ManagedStartupProfile; + readonly encodedProfile: string; + readonly startupProfileSha256: string; + readonly corporateCaB64?: string; +} + +export class ManagedStartupCloneRebindError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(`Cannot prepare managed snapshot clone: ${message}`, options); + this.name = "ManagedStartupCloneRebindError"; + } +} + +function fail(message: string, cause?: unknown): never { + throw new ManagedStartupCloneRebindError(message, cause === undefined ? undefined : { cause }); +} + +function requireSandboxName(value: string, label: string): string { + if (!isValidName(value)) fail(`${label} sandbox name is invalid`); + return value; +} + +function requireDestinationPort(port: number | null, agent: ManagedStartupAgent): number { + if (!Number.isInteger(port) || port === null || port < 1024 || port > 65_535) { + fail(`${agent} requires an allocated destination dashboard port`); + } + return port; +} + +function urlAtPort(raw: string, port: number): string { + let parsed: URL; + try { + parsed = new URL(raw); + } catch (error) { + fail("source dashboard URL is invalid", error); + } + parsed.port = String(port); + return parsed.toString(); +} + +function requireCurrentString(value: unknown, label: string): string { + if (typeof value !== "string" || value.trim() === "" || value !== value.trim()) { + fail(`current source ${label} is missing or invalid`); + } + return value; +} + +function optionalCurrentString(value: unknown, label: string): string | null { + if (value === null || value === undefined) return null; + return requireCurrentString(value, label); +} + +function currentInference( + profile: ManagedStartupProfile, + current: ManagedStartupCloneCurrentState, +): ManagedStartupProfile["inference"] { + const provider = requireCurrentString(current.provider, "inference provider"); + const model = requireCurrentString(current.model, "inference model"); + const preferredApi = optionalCurrentString( + current.preferredInferenceApi, + "preferred inference API", + ); + if (preferredApi !== null && !MANAGED_INFERENCE_API_SET.has(preferredApi)) { + fail("current source preferred inference API is unsupported"); + } + const resolved = resolveManagedStartupInferenceRoute( + profile.agent, + provider, + model, + preferredApi, + ); + if (!MANAGED_INFERENCE_API_SET.has(resolved.inferenceApi)) { + fail("current source inference route resolved an unsupported API"); + } + const upstreamEndpointUrl = + profile.agent === "langchain-deepagents-code" + ? optionalCurrentString(current.endpointUrl, "upstream endpoint URL") + : null; + return { + routeProvider: resolved.providerKey, + upstreamProvider: provider, + model, + routedBaseUrl: resolved.inferenceBaseUrl, + upstreamEndpointUrl, + api: resolved.inferenceApi as ManagedStartupProfile["inference"]["api"], + primaryModelRef: profile.agent === "openclaw" ? resolved.primaryModelRef : null, + compatibility: + profile.agent === "openclaw" + ? (JSON.parse(JSON.stringify(resolved.inferenceCompat ?? {})) as ManagedStartupJsonObject) + : null, + inputModalities: profile.agent === "openclaw" ? profile.inference.inputModalities : null, + }; +} + +function currentToolDisclosure( + current: ManagedStartupCloneCurrentState, +): ManagedStartupProfile["tools"]["disclosure"] { + const value = current.toolDisclosure ?? DEFAULT_TOOL_DISCLOSURE; + if (value !== "progressive" && value !== "direct") { + fail("current source tool disclosure is invalid"); + } + return value; +} + +function currentWebSearch( + profile: ManagedStartupProfile, + current: ManagedStartupCloneCurrentState, +): Extract["webSearch"] { + if (profile.agentConfig.agent === "langchain-deepagents-code") { + fail("DCode cannot carry web-search state"); + } + const enabled = current.webSearchEnabled === true; + const configuredProvider = current.webSearchProvider; + if ( + configuredProvider !== undefined && + configuredProvider !== null && + configuredProvider !== "brave" && + configuredProvider !== "tavily" + ) { + fail("current source web-search provider is invalid"); + } + const provider = enabled + ? configuredProvider + : (configuredProvider ?? profile.agentConfig.webSearch.provider); + if (provider !== "brave" && provider !== "tavily") { + fail("enabled current source web search has no valid provider"); + } + if (profile.agent === "hermes" && provider !== "tavily") { + fail("current Hermes web search must use Tavily"); + } + return { enabled, provider }; +} + +function currentAgentConfig( + profile: ManagedStartupProfile, + current: ManagedStartupCloneCurrentState, +): ManagedStartupProfile["agentConfig"] { + if (profile.agentConfig.agent !== "langchain-deepagents-code") { + return { + ...profile.agentConfig, + webSearch: currentWebSearch(profile, current), + }; + } + const autoApprovalMode = current.dcodeAutoApprovalMode ?? "disabled"; + if (autoApprovalMode !== "disabled" && autoApprovalMode !== "thread-opt-in") { + fail("current DCode auto-approval mode is invalid"); + } + return { + agent: "langchain-deepagents-code", + autoApprovalMode, + observabilityEnabled: current.observabilityEnabled === true, + }; +} + +function currentSourceDashboard( + profile: ManagedStartupProfile, + current: ManagedStartupCloneCurrentState, +): ManagedStartupDashboard { + if (profile.dashboard.agent === "openclaw") { + const port = + current.dashboardPort === undefined || current.dashboardPort === null + ? profile.dashboard.port + : requireDestinationPort(current.dashboardPort, profile.agent); + const remoteBind = current.dashboardRemoteBindPrepared === true; + if (remoteBind !== (profile.dashboard.bindAddress === "0.0.0.0")) { + fail("current OpenClaw dashboard bind state conflicts with its managed receipt"); + } + return { + ...profile.dashboard, + url: urlAtPort(profile.dashboard.url, port), + port, + }; + } + if (profile.dashboard.agent === "hermes") { + if (current.hermesDashboardEnabled !== true) { + return { + agent: "hermes", + mode: "disabled", + url: profile.dashboard.url, + publicPort: null, + internalPort: null, + tuiEnabled: false, + }; + } + const publicPort = requireDestinationPort( + current.hermesDashboardPort ?? current.dashboardPort ?? null, + profile.agent, + ); + const internalPort = requireDestinationPort( + current.hermesDashboardInternalPort ?? null, + profile.agent, + ); + return { + agent: "hermes", + mode: "loopback-forwarded", + url: urlAtPort(profile.dashboard.url, publicPort), + publicPort, + internalPort, + tuiEnabled: current.hermesDashboardTui === true, + }; + } + return profile.dashboard; +} + +function currentMessagingPlan(current: ManagedStartupCloneCurrentState): unknown | null { + if (current.messaging === undefined || current.messaging === null) return null; + if (current.messaging.schemaVersion !== 1 || current.messaging.plan === undefined) { + fail("current source messaging state is invalid"); + } + return current.messaging.plan; +} + +function reconcileCurrentSourceProfile( + profile: ManagedStartupProfile, + current: ManagedStartupCloneCurrentState, +): ManagedStartupProfile { + const inference = currentInference(profile, current); + const hermesToolGateways = current.hermesToolGateways ?? []; + if ( + !Array.isArray(hermesToolGateways) || + !hermesToolGateways.every((value) => typeof value === "string") + ) { + fail("current Hermes tool gateways are invalid"); + } + let reasoning = profile.tuning.reasoning; + let reasoningEffort = profile.tuning.reasoningEffort; + let contextWindow = profile.tuning.contextWindow; + if (profile.agent === "openclaw") { + const currentReasoning = current.compatibleEndpointReasoning; + if ( + currentReasoning !== undefined && + currentReasoning !== null && + currentReasoning !== "true" && + currentReasoning !== "false" + ) { + fail("current source compatible-endpoint reasoning state is invalid"); + } + reasoning = current.provider === "compatible-endpoint" ? currentReasoning === "true" : false; + const currentReasoningEffort = current.compatibleEndpointReasoningEffort; + if ( + currentReasoningEffort !== undefined && + currentReasoningEffort !== null && + currentReasoningEffort !== "low" && + currentReasoningEffort !== "medium" && + currentReasoningEffort !== "high" + ) { + fail("current source compatible-endpoint reasoning-effort state is invalid"); + } + reasoningEffort = + inference.upstreamProvider === "compatible-endpoint" && inference.api === "openai-completions" + ? (currentReasoningEffort ?? "default") + : "default"; + if ( + profile.inference.upstreamProvider !== current.provider || + profile.inference.model !== current.model + ) { + contextWindow = resolveContextWindowForModel( + requireCurrentString(current.provider, "inference provider"), + requireCurrentString(current.model, "inference model"), + ); + if (contextWindow === null) { + fail("current OpenClaw inference route has no verifiable context window"); + } + } + } + return validateManagedStartupProfile({ + ...profile, + agentConfig: currentAgentConfig(profile, current), + inference, + dashboard: currentSourceDashboard(profile, current), + tools: { + disclosure: currentToolDisclosure(current), + enabledGateways: profile.agent === "hermes" ? [...hermesToolGateways] : [], + }, + messaging: { plan: currentMessagingPlan(current) }, + tuning: { ...profile.tuning, contextWindow, reasoning, reasoningEffort }, + }); +} + +function destinationDashboard( + profile: ManagedStartupProfile, + destinationDashboardPort: number | null, +): ManagedStartupDashboard { + const dashboard = profile.dashboard; + if (dashboard.agent === "openclaw") { + const port = requireDestinationPort(destinationDashboardPort, profile.agent); + return { + ...dashboard, + url: urlAtPort(dashboard.url, port), + port, + }; + } + if (dashboard.agent === "hermes") { + if (dashboard.mode === "disabled") { + if (destinationDashboardPort === null) { + return { + ...dashboard, + url: "http://127.0.0.1/", + }; + } + return { + ...dashboard, + url: urlAtPort(dashboard.url, destinationDashboardPort), + }; + } + const port = requireDestinationPort(destinationDashboardPort, profile.agent); + return { + ...dashboard, + url: urlAtPort(dashboard.url, port), + publicPort: port, + }; + } + if (destinationDashboardPort !== null) { + fail("langchain-deepagents-code cannot accept a destination dashboard port"); + } + return dashboard; +} + +function destinationMessagingPlan( + profile: ManagedStartupProfile, + sourceSandboxName: string, + destinationSandboxName: string, +): ManagedStartupJsonObject | null { + if (profile.messaging.plan === null) return null; + if (profile.agent === "langchain-deepagents-code") { + fail("langchain-deepagents-code cannot carry a messaging plan"); + } + const rebound = rebindSandboxMessagingPlanForClone({ + sourceSandboxName, + destinationSandboxName, + agent: profile.agent, + sourcePlan: profile.messaging.plan, + environment: { + NEMOCLAW_PROXY_HOST: profile.proxy.managedHost, + NEMOCLAW_PROXY_PORT: String(profile.proxy.managedPort), + }, + }); + return JSON.parse(JSON.stringify(rebound)) as ManagedStartupJsonObject; +} + +function destinationInference( + profile: ManagedStartupProfile, + input: ManagedStartupCloneRebindInput, +): ManagedStartupProfile["inference"] { + if (profile.agent !== "hermes" || profile.tools.enabledGateways.length === 0) { + return profile.inference; + } + const provider = requireCurrentString( + input.destinationHermesInferenceProvider, + "destination Hermes inference provider", + ); + return { + ...profile.inference, + upstreamProvider: provider, + }; +} + +/** + * Verify a source managed receipt transport and bind its secret-free intent to + * a newly allocated destination identity before any snapshot mutation occurs. + */ +export function rebindManagedStartupProfileForClone( + input: ManagedStartupCloneRebindInput, +): ReboundManagedStartupClone { + const sourceSandboxName = requireSandboxName(input.sourceSandboxName, "source"); + const destinationSandboxName = requireSandboxName(input.destinationSandboxName, "destination"); + if (sourceSandboxName === destinationSandboxName) { + fail("source and destination sandbox names must differ"); + } + if ( + !SHA256_PATTERN.test(input.startupProfileSha256) || + createHash("sha256").update(input.encodedProfile, "utf8").digest("hex") !== + input.startupProfileSha256 + ) { + fail("source profile transport does not match its receipt SHA-256 digest"); + } + + let sourceProfile: ManagedStartupProfile; + try { + sourceProfile = decodeManagedStartupProfile(input.encodedProfile); + } catch (error) { + fail("source profile transport is not canonical and valid", error); + } + if (sourceProfile.agent !== input.expectedAgent) { + fail(`source profile targets ${sourceProfile.agent}, expected ${input.expectedAgent}`); + } + try { + validateManagedStartupCorporateCaTransport(input.corporateCaB64, sourceProfile); + } catch (error) { + fail("source corporate CA transport does not match the profile", error); + } + + let profile: ManagedStartupProfile; + try { + const currentSourceProfile = reconcileCurrentSourceProfile(sourceProfile, input.currentSource); + profile = validateManagedStartupProfile({ + ...currentSourceProfile, + inference: destinationInference(currentSourceProfile, input), + dashboard: destinationDashboard(currentSourceProfile, input.destinationDashboardPort), + messaging: { + plan: destinationMessagingPlan( + currentSourceProfile, + sourceSandboxName, + destinationSandboxName, + ), + }, + }); + } catch (error) { + if (error instanceof ManagedStartupCloneRebindError) throw error; + const detail = error instanceof Error ? `: ${error.message}` : ""; + fail(`destination profile could not be validated${detail}`, error); + } + + const encodedProfile = encodeManagedStartupProfile(profile); + const startupProfileSha256 = createHash("sha256").update(encodedProfile, "utf8").digest("hex"); + return cloneAndDeepFreeze( + input.corporateCaB64 === undefined + ? { profile, encodedProfile, startupProfileSha256 } + : { + profile, + encodedProfile, + startupProfileSha256, + corporateCaB64: input.corporateCaB64, + }, + ); +} diff --git a/src/lib/onboard/managed-workload-clone-handoff.test.ts b/src/lib/onboard/managed-workload-clone-handoff.test.ts new file mode 100644 index 00000000000..98bb6230a57 --- /dev/null +++ b/src/lib/onboard/managed-workload-clone-handoff.test.ts @@ -0,0 +1,419 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from "node:crypto"; + +import { describe, expect, it, vi } from "vitest"; +import { managedStartupE2eProfile } from "../../../scripts/checks/generate-managed-startup-profile-fixture.mts"; +import { createInMemoryRuntimeProviderBundle } from "../../../test/helpers/runtime-provider-bundle"; +import type { SandboxEntry, SandboxWorkloadReceipt } from "../state/registry/types"; +import { + MANAGED_IMAGE_REPOSITORIES, + type ShippedManagedImageAgent, +} from "./managed-image/contract"; +import { + encodeManagedStartupProfile, + type ManagedStartupProfile, + validateManagedStartupProfile, +} from "./managed-startup/profile"; +import type { + RuntimeProviderBundle, + RuntimeProviderWorkloadProfile, +} from "./runtime-provider/contract"; +import { CURRENT_RUNTIME_PROVIDER_BUNDLES } from "./runtime-provider/current"; +import { ManagedWorkloadCloneError, prepareManagedWorkloadCloneHandoff } from "./workload/clone"; + +const PORTABLE_PROFILE = { + support: { + exactDigestReferences: true, + platforms: ["linux/amd64", "linux/arm64"], + startupProfileContractVersions: [1], + capabilityContractVersions: [1], + }, + hostArchitectures: ["amd64", "arm64"], + managedImageSelectionPolicy: "require-managed", + legacyDockerfileBuilds: false, +} as const satisfies RuntimeProviderWorkloadProfile; + +function provider(providerId: "docker" | "mxc"): RuntimeProviderBundle { + return providerId === "docker" + ? CURRENT_RUNTIME_PROVIDER_BUNDLES.docker! + : createInMemoryRuntimeProviderBundle({ + providerId, + workloadProfile: PORTABLE_PROFILE, + }); +} + +function receipt( + agent: ShippedManagedImageAgent, + profile: ManagedStartupProfile, +): Extract { + const encodedProfile = encodeManagedStartupProfile(profile); + return { + schemaVersion: 1, + kind: "managed-image", + reference: `${MANAGED_IMAGE_REPOSITORIES[agent]}@sha256:${"a".repeat(64)}`, + platform: "linux/amd64", + release: "v0.0.99", + sourceRevision: "b".repeat(40), + sourceCohort: "ghrun-123456-1", + capabilityContractVersion: 1, + startupProfileContractVersion: 1, + encodedProfile, + startupProfileSha256: createHash("sha256").update(encodedProfile, "utf8").digest("hex"), + credentialProxyReplayRequired: true, + shared: true, + }; +} + +function source( + agent: ShippedManagedImageAgent, + providerId: "docker" | "mxc", + profile = managedStartupE2eProfile(agent), +): SandboxEntry { + const workload = receipt(agent, profile); + return { + name: "source", + agent, + openshellDriver: providerId, + imageTag: workload.reference, + workload, + lifecycleGeneration: "generation-source-current", + lifecycleLiveIdentityFingerprint: "f".repeat(64), + provider: profile.inference.upstreamProvider, + model: profile.inference.model, + endpointUrl: profile.inference.upstreamEndpointUrl, + endpointSource: profile.inference.upstreamEndpointUrl ? "onboard" : null, + credentialEnv: "NVIDIA_API_KEY", + preferredInferenceApi: profile.inference.api, + compatibleEndpointReasoning: null, + compatibleEndpointReasoningEffort: null, + toolDisclosure: profile.tools.disclosure, + webSearchEnabled: + profile.agentConfig.agent === "langchain-deepagents-code" + ? false + : profile.agentConfig.webSearch.enabled, + webSearchProvider: + profile.agentConfig.agent === "langchain-deepagents-code" + ? null + : profile.agentConfig.webSearch.provider, + ...(profile.messaging.plan === null + ? {} + : { + messaging: { + schemaVersion: 1 as const, + plan: profile.messaging.plan as unknown as NonNullable< + SandboxEntry["messaging"] + >["plan"], + }, + }), + ...(profile.dashboard.agent === "openclaw" + ? { + dashboardPort: profile.dashboard.port, + dashboardRemoteBindPrepared: profile.dashboard.bindAddress === "0.0.0.0", + } + : {}), + ...(profile.agent === "hermes" && profile.tools.enabledGateways.length > 0 + ? { hermesToolGateways: [...profile.tools.enabledGateways] } + : {}), + ...(profile.agentConfig.agent === "langchain-deepagents-code" + ? { + dcodeAutoApprovalMode: profile.agentConfig.autoApprovalMode, + observabilityEnabled: profile.agentConfig.observabilityEnabled, + } + : {}), + }; +} + +function runtimeSnapshot(providerId: string) { + return { + schemaVersion: 1 as const, + providerId, + providerHandle: `${providerId}:snapshot:source`, + lifecycleState: "running" as const, + lifecycleGeneration: "generation-source-1", + runtime: { + schemaVersion: 1 as const, + providerId, + runtime: { kind: `${providerId}-workload`, handle: `${providerId}:runtime:source` }, + acceleration: { + kind: "gpu" as const, + vendor: "nvidia", + devices: ["nvidia.com/gpu=0"], + }, + }, + }; +} + +function restoreAuthority() { + return { + schemaVersion: 1 as const, + backupPath: "/tmp/nemoclaw-managed-clone-source", + contentSha256: "c".repeat(64), + }; +} + +function prepare( + entry: SandboxEntry, + selectedProvider: RuntimeProviderBundle, + getHermesInferenceProviderName = vi.fn( + (sandboxName: string) => `${sandboxName}-hermes-inference`, + ), + destinationSandboxName = "destination", +) { + return prepareManagedWorkloadCloneHandoff({ + source: entry, + snapshot: { + sandboxName: entry.name, + agentType: entry.agent!, + workload: entry.workload, + runtimeSnapshot: runtimeSnapshot(selectedProvider.identity.id), + restoreAuthority: restoreAuthority(), + }, + destinationSandboxName, + destinationDashboardPort: entry.agent === "openclaw" ? 20_789 : null, + provider: selectedProvider, + getHermesInferenceProviderName, + }); +} + +describe("prepareManagedWorkloadCloneHandoff", () => { + it.each([ + ["docker", "openclaw"], + ["docker", "hermes"], + ["docker", "langchain-deepagents-code"], + ["mxc", "openclaw"], + ["mxc", "hermes"], + ["mxc", "langchain-deepagents-code"], + ] as const)("keeps %s clone handoff provider-bound for %s", (providerId, agent) => { + const selectedProvider = provider(providerId); + const entry = source(agent, providerId); + + const handoff = prepare(entry, selectedProvider); + + expect(handoff).toMatchObject({ + schemaVersion: 1, + phase: "rebound", + providerId, + sourceSandboxName: "source", + destinationSandboxName: "destination", + sourceRegistryAuthority: { + providerId, + lifecycleGeneration: "generation-source-current", + liveIdentityFingerprint: "f".repeat(64), + }, + runtimeSnapshot: { + providerId, + runtime: { + providerId, + acceleration: { kind: "gpu", vendor: "nvidia" }, + }, + }, + snapshotRestoreAuthority: restoreAuthority(), + workload: { + kind: "managed-image", + reference: entry.imageTag, + platform: "linux/amd64", + }, + registryFields: { + model: entry.model, + preferredInferenceApi: "openai-completions", + }, + }); + expect(handoff.rebound.profile.agent).toBe(agent); + expect(handoff.workload.encodedProfile).toBe(handoff.rebound.encodedProfile); + expect(JSON.stringify(handoff)).not.toContain("podman"); + expect(Object.isFrozen(handoff)).toBe(true); + expect(Object.isFrozen(handoff.sourceAuthority.profile)).toBe(true); + expect(Object.isFrozen(handoff.runtimeSnapshot.runtime.acceleration)).toBe(true); + expect(Object.isFrozen(handoff.rebound.profile.tools.enabledGateways)).toBe(true); + }); + + it("rebinds managed-tool Hermes to an injected destination provider identity", () => { + const profile = validateManagedStartupProfile({ + ...managedStartupE2eProfile("hermes"), + tools: { + disclosure: "direct", + enabledGateways: ["nous-web"], + }, + }); + const entry = source("hermes", "mxc", profile); + const resolver = vi.fn(() => "mxc-destination-inference"); + + const handoff = prepare(entry, provider("mxc"), resolver); + + expect(resolver).toHaveBeenCalledWith("destination"); + expect(handoff.rebound.profile).toMatchObject({ + inference: { upstreamProvider: "mxc-destination-inference" }, + tools: { disclosure: "direct", enabledGateways: ["nous-web"] }, + }); + expect(handoff.registryFields).toMatchObject({ + provider: "nvidia", + hermesInferenceProvider: "mxc-destination-inference", + hermesToolGateways: ["nous-web"], + }); + }); + + it("carries the destination-bound messaging plan as typed registry state", () => { + const profile = validateManagedStartupProfile({ + ...managedStartupE2eProfile("openclaw"), + messaging: { + plan: { + schemaVersion: 1, + sandboxName: "source", + agent: "openclaw", + workflow: "onboard", + channels: [ + { + channelId: "telegram", + configured: true, + active: true, + disabled: false, + inputs: [ + { inputId: "botToken", credentialAvailable: true }, + { inputId: "allowedIds", value: ["123456"] }, + ], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + stateUpdates: [], + healthChecks: [], + }, + }, + }); + const entry = source("openclaw", "docker", profile); + + const handoff = prepare(entry, provider("docker")); + + expect(handoff.messaging).toMatchObject({ + schemaVersion: 1, + plan: { + sandboxName: "destination", + credentialBindings: [{ providerName: "destination-telegram-bridge" }], + }, + }); + expect(JSON.stringify(handoff.messaging)).not.toContain("credentialHash"); + }); + + it("never gives provider receipt validation a mutable authority value", () => { + const base = provider("mxc"); + const observedReceipts: SandboxWorkloadReceipt[] = []; + const mutationResults: boolean[] = []; + const hostileProvider: RuntimeProviderBundle = { + ...base, + workload: { + ...base.workload, + acceptsReceipt: (candidate) => { + const managedCandidates = candidate?.kind === "managed-image" ? [candidate] : []; + for (const managedCandidate of managedCandidates) { + observedReceipts.push(managedCandidate); + mutationResults.push(Reflect.set(managedCandidate, "reference", "mutated-by-provider")); + } + return true; + }, + }, + }; + const entry = source("openclaw", "mxc"); + + const handoff = prepare(entry, hostileProvider); + + expect(observedReceipts).toHaveLength(2); + expect(observedReceipts.every((candidate) => Object.isFrozen(candidate))).toBe(true); + expect(mutationResults).toEqual([false, false]); + expect(handoff.workload.reference).toBe(entry.imageTag); + }); + + it("fails closed on stale managed authority, provider drift, and missing clone authority", () => { + const entry = source("openclaw", "mxc"); + const selectedProvider = provider("mxc"); + expect(() => + prepareManagedWorkloadCloneHandoff({ + source: entry, + snapshot: { + sandboxName: "source", + agentType: "openclaw", + workload: { + ...(entry.workload as Extract< + SandboxWorkloadReceipt, + { readonly kind: "managed-image" } + >), + sourceRevision: "c".repeat(40), + }, + runtimeSnapshot: runtimeSnapshot("mxc"), + restoreAuthority: restoreAuthority(), + }, + destinationSandboxName: "destination", + destinationDashboardPort: 20_789, + provider: selectedProvider, + getHermesInferenceProviderName: vi.fn(), + }), + ).toThrow(/no longer matches/u); + + expect(() => prepare(entry, provider("docker"))).toThrow(/does not match selected provider/u); + + const unauthorized = { + ...selectedProvider, + mutationAuthority: { + ...selectedProvider.mutationAuthority, + operations: + selectedProvider.mutationAuthority.supported === true + ? selectedProvider.mutationAuthority.operations.filter( + (operation) => operation !== "clone", + ) + : [], + }, + } as RuntimeProviderBundle; + expect(() => prepare(entry, unauthorized)).toThrow(ManagedWorkloadCloneError); + }); + + it("uses the canonical sandbox and provider grammars at the exported boundary", () => { + const profile = validateManagedStartupProfile({ + ...managedStartupE2eProfile("hermes"), + tools: { disclosure: "direct", enabledGateways: ["nous-web"] }, + }); + const entry = source("hermes", "docker", profile); + const longestSandboxName = `a${"b".repeat(62)}`; + + expect(() => prepare(entry, provider("docker"), undefined, longestSandboxName)).not.toThrow(); + expect(() => prepare(entry, provider("docker"), undefined, "1destination")).toThrow( + /destination sandbox name is invalid/u, + ); + expect(() => + prepare( + entry, + provider("docker"), + vi.fn(() => "1provider"), + ), + ).toThrow(/destination Hermes inference provider name is invalid/u); + }); + + it("rejects malformed snapshot content authority before exposing a handoff", () => { + const entry = source("openclaw", "docker"); + + expect(() => + prepareManagedWorkloadCloneHandoff({ + source: entry, + snapshot: { + sandboxName: entry.name, + agentType: entry.agent!, + workload: entry.workload, + runtimeSnapshot: runtimeSnapshot("docker"), + restoreAuthority: { + schemaVersion: 1, + backupPath: "relative/snapshot", + contentSha256: "c".repeat(64), + }, + }, + destinationSandboxName: "destination", + destinationDashboardPort: 20_789, + provider: provider("docker"), + getHermesInferenceProviderName: vi.fn(), + }), + ).toThrow(/snapshot content authority is invalid/u); + }); +}); diff --git a/src/lib/onboard/runtime-provider/contract.ts b/src/lib/onboard/runtime-provider/contract.ts index 644239b3a7a..8888d95f83a 100644 --- a/src/lib/onboard/runtime-provider/contract.ts +++ b/src/lib/onboard/runtime-provider/contract.ts @@ -17,6 +17,7 @@ export type RuntimeProviderMutationOperation = | "stop" | "inference-set" | "rebuild" + | "clone" | "provider-cleanup" | "destroy" | "workload-cleanup"; diff --git a/src/lib/onboard/runtime-provider/docker.ts b/src/lib/onboard/runtime-provider/docker.ts index f8481c37c3c..63945928895 100644 --- a/src/lib/onboard/runtime-provider/docker.ts +++ b/src/lib/onboard/runtime-provider/docker.ts @@ -347,6 +347,7 @@ export function createDockerRuntimeProviderBundle( "stop", "inference-set", "rebuild", + "clone", "provider-cleanup", "destroy", "workload-cleanup", diff --git a/src/lib/onboard/runtime-provider/registry.ts b/src/lib/onboard/runtime-provider/registry.ts index 765f1feed9f..332792d2dc2 100644 --- a/src/lib/onboard/runtime-provider/registry.ts +++ b/src/lib/onboard/runtime-provider/registry.ts @@ -59,6 +59,7 @@ const MUTATION_OPERATIONS = new Set([ "stop", "inference-set", "rebuild", + "clone", "provider-cleanup", "destroy", "workload-cleanup", diff --git a/src/lib/onboard/workload/clone.ts b/src/lib/onboard/workload/clone.ts new file mode 100644 index 00000000000..72e9b700841 --- /dev/null +++ b/src/lib/onboard/workload/clone.ts @@ -0,0 +1,389 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isAbsolute, resolve } from "node:path"; +import { isDeepStrictEqual } from "node:util"; + +import { cloneAndDeepFreeze } from "../../core/immutable"; +import { createBuiltInChannelManifestRegistry } from "../../messaging/channels/built-ins"; +import { parseSandboxMessagingPlan } from "../../messaging/plan-validation"; +import { isValidName, isValidProviderName } from "../../name-validation"; +import { + captureSandboxRebuildAuthority, + type SandboxRebuildAuthority, +} from "../../state/registry/rebuild-authority"; +import { + cloneSandboxRuntimeSnapshot, + type SandboxRuntimeSnapshot, +} from "../../state/registry/runtime-snapshot"; +import type { SandboxEntry, SandboxWorkloadReceipt } from "../../state/registry/types"; +import { cloneSandboxWorkloadReceipt } from "../../state/registry/workload"; +import type { SandboxMessagingState } from "../../state/registry-messaging"; +import type { SnapshotRestoreAuthority } from "../../state/sandbox"; +import { + type ReboundManagedStartupClone, + rebindManagedStartupProfileForClone, +} from "../managed-startup/clone-rebinder"; +import type { ManagedStartupProfile } from "../managed-startup/profile"; +import type { RuntimeProviderBundle } from "../runtime-provider/contract"; +import { + normalizeRuntimeProviderIdentity, + requireRuntimeProviderMutationAuthority, +} from "../runtime-provider/registry"; +import { type ManagedWorkloadAuthority, readManagedWorkloadAuthority } from "./authority"; + +export interface ManagedWorkloadCloneSnapshot { + readonly sandboxName: string; + readonly agentType: string; + readonly workload?: SandboxWorkloadReceipt; + readonly runtimeSnapshot?: SandboxRuntimeSnapshot; + /** Exact selected manifest and payload identity captured by the state layer. */ + readonly restoreAuthority: SnapshotRestoreAuthority; +} + +export interface ManagedWorkloadCloneRegistryFields { + readonly provider: SandboxEntry["provider"]; + readonly model: SandboxEntry["model"]; + readonly endpointUrl: SandboxEntry["endpointUrl"]; + readonly endpointSource: SandboxEntry["endpointSource"]; + readonly credentialEnv: SandboxEntry["credentialEnv"]; + readonly preferredInferenceApi: SandboxEntry["preferredInferenceApi"]; + readonly compatibleEndpointReasoning: SandboxEntry["compatibleEndpointReasoning"]; + readonly compatibleEndpointReasoningEffort: SandboxEntry["compatibleEndpointReasoningEffort"]; + readonly toolDisclosure: SandboxEntry["toolDisclosure"]; + readonly webSearchEnabled: SandboxEntry["webSearchEnabled"]; + readonly webSearchProvider: SandboxEntry["webSearchProvider"]; + readonly observabilityEnabled: SandboxEntry["observabilityEnabled"]; + readonly dcodeAutoApprovalMode?: SandboxEntry["dcodeAutoApprovalMode"]; + readonly hermesToolGateways?: readonly string[]; + readonly hermesInferenceProvider?: string; + readonly hermesDashboardEnabled?: true; + readonly hermesDashboardPort?: number; + readonly hermesDashboardInternalPort?: number; + readonly hermesDashboardTui?: true; + readonly dashboardPort?: number; + readonly dashboardRemoteBindPrepared: boolean; +} + +export interface PreparedManagedWorkloadCloneHandoff { + readonly schemaVersion: 1; + readonly phase: "rebound"; + readonly providerId: string; + readonly sourceSandboxName: string; + readonly destinationSandboxName: string; + /** Exact current registry row authority to revalidate at the mutation edge. */ + readonly sourceRegistryAuthority: SandboxRebuildAuthority; + readonly sourceAuthority: ManagedWorkloadAuthority; + readonly runtimeSnapshot: SandboxRuntimeSnapshot; + readonly snapshotRestoreAuthority: SnapshotRestoreAuthority; + readonly rebound: ReboundManagedStartupClone; + readonly workload: Extract; + readonly messaging?: SandboxMessagingState; + readonly registryFields: ManagedWorkloadCloneRegistryFields; +} + +export interface PrepareManagedWorkloadCloneHandoffInput { + readonly source: SandboxEntry; + readonly snapshot: ManagedWorkloadCloneSnapshot; + readonly destinationSandboxName: string; + readonly destinationDashboardPort: number | null; + readonly provider: RuntimeProviderBundle; + /** + * Provider-name ownership stays outside central clone orchestration. Hermes + * supplies this resolver only when managed tools require a destination key. + */ + readonly getHermesInferenceProviderName: (sandboxName: string) => string; +} + +export class ManagedWorkloadCloneError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(`Managed workload clone preflight failed: ${message}`, options); + this.name = "ManagedWorkloadCloneError"; + } +} + +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const MAX_AUTHORITY_PATH_BYTES = 4096; + +function fail(message: string, cause?: unknown): never { + throw new ManagedWorkloadCloneError(message, cause === undefined ? undefined : { cause }); +} + +function requireSandboxName(value: string, label: string): string { + if (!isValidName(value)) fail(`${label} sandbox name is invalid`); + return value; +} + +function requireProviderName(value: string, label: string): string { + if (!isValidProviderName(value)) fail(`${label} provider name is invalid`); + return value; +} + +function cloneSnapshotRestoreAuthority(value: SnapshotRestoreAuthority): SnapshotRestoreAuthority { + if ( + value?.schemaVersion !== 1 || + typeof value.backupPath !== "string" || + !isAbsolute(value.backupPath) || + resolve(value.backupPath) !== value.backupPath || + value.backupPath.includes("\0") || + Buffer.byteLength(value.backupPath, "utf8") > MAX_AUTHORITY_PATH_BYTES || + typeof value.contentSha256 !== "string" || + !SHA256_PATTERN.test(value.contentSha256) + ) { + fail("snapshot content authority is invalid"); + } + return { + schemaVersion: 1, + backupPath: value.backupPath, + contentSha256: value.contentSha256, + }; +} + +function readSnapshotAuthority(snapshot: ManagedWorkloadCloneSnapshot): ManagedWorkloadAuthority { + let authority: ManagedWorkloadAuthority | null; + try { + authority = readManagedWorkloadAuthority({ + agent: snapshot.agentType, + fromDockerfile: null, + imageTag: + snapshot.workload?.kind === "managed-image" ? snapshot.workload.reference : undefined, + workload: snapshot.workload, + }); + } catch (error) { + fail(`snapshot '${snapshot.sandboxName}' has invalid managed workload authority`, error); + } + if (!authority) fail(`snapshot '${snapshot.sandboxName}' is not a managed workload`); + return authority; +} + +function registryFields( + profile: ManagedStartupProfile, + source: SandboxEntry, +): ManagedWorkloadCloneRegistryFields { + const webSearch = + profile.agentConfig.agent === "langchain-deepagents-code" + ? null + : profile.agentConfig.webSearch; + const hermesDashboard = profile.dashboard.agent === "hermes" ? profile.dashboard : null; + const dcodeConfig = + profile.agentConfig.agent === "langchain-deepagents-code" ? profile.agentConfig : null; + const hermesInferenceProvider = + profile.agent === "hermes" && profile.tools.enabledGateways.length > 0 + ? profile.inference.upstreamProvider + : undefined; + const dashboardPort = + profile.dashboard.agent === "openclaw" + ? profile.dashboard.port + : hermesDashboard?.mode === "loopback-forwarded" + ? hermesDashboard.publicPort + : undefined; + return { + // Snapshot peers retain the source gateway route. The isolated Hermes + // provider owns only the destination sandbox's rotating runtime key. + provider: + hermesInferenceProvider === undefined ? profile.inference.upstreamProvider : source.provider, + model: profile.inference.model, + endpointUrl: source.endpointUrl ?? null, + endpointSource: source.endpointSource ?? null, + credentialEnv: source.credentialEnv ?? null, + preferredInferenceApi: profile.inference.api, + compatibleEndpointReasoning: + profile.agent === "openclaw" && profile.inference.upstreamProvider === "compatible-endpoint" + ? profile.tuning.reasoning + ? "true" + : "false" + : null, + compatibleEndpointReasoningEffort: + profile.agent === "openclaw" && + profile.inference.upstreamProvider === "compatible-endpoint" && + profile.inference.api === "openai-completions" && + profile.tuning.reasoningEffort !== "default" + ? profile.tuning.reasoningEffort + : null, + toolDisclosure: profile.tools.disclosure, + webSearchEnabled: webSearch?.enabled === true, + webSearchProvider: webSearch?.enabled === true ? webSearch.provider : null, + observabilityEnabled: dcodeConfig?.observabilityEnabled === true, + ...(dcodeConfig ? { dcodeAutoApprovalMode: dcodeConfig.autoApprovalMode } : {}), + ...(profile.agent === "hermes" && profile.tools.enabledGateways.length > 0 + ? { hermesToolGateways: [...profile.tools.enabledGateways] } + : {}), + ...(hermesInferenceProvider === undefined ? {} : { hermesInferenceProvider }), + ...(hermesDashboard?.mode === "loopback-forwarded" + ? { + hermesDashboardEnabled: true as const, + hermesDashboardPort: hermesDashboard.publicPort, + hermesDashboardInternalPort: hermesDashboard.internalPort, + ...(hermesDashboard.tuiEnabled ? { hermesDashboardTui: true as const } : {}), + } + : {}), + ...(dashboardPort === undefined ? {} : { dashboardPort }), + dashboardRemoteBindPrepared: + profile.dashboard.agent === "openclaw" && profile.dashboard.bindAddress === "0.0.0.0", + }; +} + +function reboundWorkload( + source: ManagedWorkloadAuthority, + rebound: ReboundManagedStartupClone, + provider: RuntimeProviderBundle, +): Extract { + const candidate = { + ...source.receipt, + encodedProfile: rebound.encodedProfile, + startupProfileSha256: rebound.startupProfileSha256, + ...(rebound.corporateCaB64 === undefined ? {} : { corporateCaB64: rebound.corporateCaB64 }), + } as const; + const normalized = cloneSandboxWorkloadReceipt(candidate); + if (normalized?.kind !== "managed-image") { + fail("rebound managed workload receipt is invalid"); + } + const immutable = cloneAndDeepFreeze(normalized); + if (!provider.workload.acceptsReceipt(immutable)) { + fail(`provider '${provider.identity.id}' rejected the rebound workload receipt`); + } + return immutable; +} + +function reboundMessaging( + profile: ManagedStartupProfile, + destinationSandboxName: string, +): SandboxMessagingState | undefined { + if (profile.messaging.plan === null) return undefined; + if (profile.agent === "langchain-deepagents-code") { + fail("DCode clone unexpectedly produced a messaging plan"); + } + const agent = profile.agent; + const manifestRegistry = createBuiltInChannelManifestRegistry(); + const plan = parseSandboxMessagingPlan(profile.messaging.plan, { + sandboxName: destinationSandboxName, + agent, + supportedChannelIds: manifestRegistry.listAvailable({ agent }).map((manifest) => manifest.id), + environment: { + NEMOCLAW_PROXY_HOST: profile.proxy.managedHost, + NEMOCLAW_PROXY_PORT: String(profile.proxy.managedPort), + }, + }); + if (!plan) fail("rebound messaging plan is invalid"); + return { schemaVersion: 1, plan }; +} + +/** + * Build the provider-bound, secret-free handoff consumed by the later held + * create/bootstrap transaction. This function performs no provider, registry, + * filesystem, credential, or sandbox mutation. + */ +export function prepareManagedWorkloadCloneHandoff( + input: PrepareManagedWorkloadCloneHandoffInput, +): PreparedManagedWorkloadCloneHandoff { + const sourceSandboxName = requireSandboxName(input.source.name, "source"); + const snapshotSandboxName = requireSandboxName(input.snapshot.sandboxName, "snapshot source"); + const destinationSandboxName = requireSandboxName(input.destinationSandboxName, "destination"); + if (sourceSandboxName !== snapshotSandboxName) { + fail("snapshot source identity does not match the current source sandbox"); + } + if (sourceSandboxName === destinationSandboxName) { + fail("source and destination sandbox names must differ"); + } + + const providerId = normalizeRuntimeProviderIdentity(input.source.openshellDriver); + if ( + providerId !== input.provider.identity.id || + input.provider.workload.providerId !== input.provider.identity.id + ) { + fail( + `source provider '${providerId}' does not match selected provider ` + + `'${input.provider.identity.id}'`, + ); + } + try { + requireRuntimeProviderMutationAuthority(input.provider, "clone"); + } catch (error) { + fail(`provider '${input.provider.identity.id}' does not authorize clone handoff`, error); + } + + let sourceRegistryAuthority: SandboxRebuildAuthority; + try { + sourceRegistryAuthority = captureSandboxRebuildAuthority( + input.source, + input.provider.identity.id, + ); + } catch (error) { + fail(`source '${sourceSandboxName}' has no exact registry generation authority`, error); + } + + let currentAuthority: ManagedWorkloadAuthority | null; + try { + currentAuthority = readManagedWorkloadAuthority(input.source); + } catch (error) { + fail(`source '${sourceSandboxName}' has invalid managed workload authority`, error); + } + if (!currentAuthority) fail(`source '${sourceSandboxName}' is not a managed workload`); + const snapshotAuthority = readSnapshotAuthority(input.snapshot); + if (!isDeepStrictEqual(currentAuthority, snapshotAuthority)) { + fail("current source managed authority no longer matches the selected snapshot"); + } + if (!input.provider.workload.acceptsReceipt(snapshotAuthority.receipt)) { + fail(`provider '${input.provider.identity.id}' rejected the snapshot workload receipt`); + } + + const runtimeSnapshot = cloneSandboxRuntimeSnapshot(input.snapshot.runtimeSnapshot); + if ( + !runtimeSnapshot || + runtimeSnapshot.providerId !== input.provider.identity.id || + runtimeSnapshot.runtime.providerId !== input.provider.identity.id + ) { + fail("snapshot runtime authority does not belong to the selected provider"); + } + const snapshotRestoreAuthority = cloneSnapshotRestoreAuthority(input.snapshot.restoreAuthority); + + const needsHermesInferenceProvider = + snapshotAuthority.agent === "hermes" && + Array.isArray(input.source.hermesToolGateways) && + input.source.hermesToolGateways.length > 0; + const destinationHermesInferenceProvider = needsHermesInferenceProvider + ? requireProviderName( + input.getHermesInferenceProviderName(destinationSandboxName), + "destination Hermes inference", + ) + : undefined; + + let rebound: ReboundManagedStartupClone; + try { + rebound = rebindManagedStartupProfileForClone({ + sourceSandboxName, + destinationSandboxName, + expectedAgent: snapshotAuthority.agent, + destinationDashboardPort: input.destinationDashboardPort, + ...(destinationHermesInferenceProvider === undefined + ? {} + : { destinationHermesInferenceProvider }), + encodedProfile: snapshotAuthority.receipt.encodedProfile, + startupProfileSha256: snapshotAuthority.receipt.startupProfileSha256, + ...(snapshotAuthority.receipt.corporateCaB64 === undefined + ? {} + : { corporateCaB64: snapshotAuthority.receipt.corporateCaB64 }), + currentSource: input.source, + }); + } catch (error) { + fail("managed startup profile could not be rebound", error); + } + const workload = reboundWorkload(snapshotAuthority, rebound, input.provider); + const messaging = reboundMessaging(rebound.profile, destinationSandboxName); + + return cloneAndDeepFreeze({ + schemaVersion: 1 as const, + phase: "rebound" as const, + providerId: input.provider.identity.id, + sourceSandboxName, + destinationSandboxName, + sourceRegistryAuthority, + sourceAuthority: snapshotAuthority, + runtimeSnapshot, + snapshotRestoreAuthority, + rebound, + workload, + ...(messaging === undefined ? {} : { messaging }), + registryFields: registryFields(rebound.profile, input.source), + }); +} diff --git a/src/lib/state/registry/types.ts b/src/lib/state/registry/types.ts index f50d21d962d..386bb16ff54 100644 --- a/src/lib/state/registry/types.ts +++ b/src/lib/state/registry/types.ts @@ -128,6 +128,8 @@ export interface SandboxEntry extends Partial { messaging?: SandboxMessagingState; mcp?: SandboxMcpState; hermesToolGateways?: string[]; + /** Destination-scoped provider holding the host-minted Hermes inference key. */ + hermesInferenceProvider?: string; hermesDashboardEnabled?: boolean; hermesDashboardPort?: number | null; hermesDashboardInternalPort?: number | null; diff --git a/test/helpers/runtime-provider-bundle.ts b/test/helpers/runtime-provider-bundle.ts index ba3e4c66811..512b544cdbc 100644 --- a/test/helpers/runtime-provider-bundle.ts +++ b/test/helpers/runtime-provider-bundle.ts @@ -150,6 +150,7 @@ export function createInMemoryRuntimeProviderBundle({ "stop", "inference-set", "rebuild", + "clone", "provider-cleanup", "destroy", "workload-cleanup",