diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 7c21b485e3..442bdfcd7d 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -303,6 +303,7 @@ const onboardSession: typeof import("./state/onboard-session") = require("./stat const { OnboardRuntimeBoundary }: typeof import("./onboard/runtime-boundary") = require("./onboard/runtime-boundary"); const { handleAgentSetupState }: typeof import("./onboard/machine/handlers/agent-setup") = require("./onboard/machine/handlers/agent-setup"); const { handleGatewayState }: typeof import("./onboard/machine/handlers/gateway") = require("./onboard/machine/handlers/gateway"); +const { handlePoliciesState }: typeof import("./onboard/machine/handlers/policies") = require("./onboard/machine/handlers/policies"); const { handlePreflightState }: typeof import("./onboard/machine/handlers/preflight") = require("./onboard/machine/handlers/preflight"); const { handleProviderInferenceState }: typeof import("./onboard/machine/handlers/provider-inference") = require("./onboard/machine/handlers/provider-inference"); const { handleSandboxState }: typeof import("./onboard/machine/handlers/sandbox") = require("./onboard/machine/handlers/sandbox"); @@ -9553,97 +9554,40 @@ async function onboard(opts: OnboardOptions = {}): Promise { }); session = agentSetupResult.session; - const latestSession = onboardSession.loadSession(); - const recordedPolicyPresets = Array.isArray(latestSession?.policyPresets) - ? latestSession.policyPresets - : null; - const recordedMessagingChannels = Array.isArray(latestSession?.messagingChannels) - ? latestSession.messagingChannels - : []; - const activeSandbox = registry.getSandbox(sandboxName); - const activeMessagingChannels = activeSandbox?.messagingChannels; - const policyMessagingChannels = mergePolicyMessagingChannels( - selectedMessagingChannels, - recordedMessagingChannels, - activeMessagingChannels, - activeSandbox?.disabledChannels, - ); - verifyCompatibleEndpointSandboxSmoke({ + const policiesResult = await handlePoliciesState({ + resume, sandboxName, provider, model, - runOpenshell, - redact, endpointUrl, credentialEnv, - messagingChannels: policyMessagingChannels, + selectedMessagingChannels, + webSearchConfig, + webSearchSupported, + hermesToolGateways, agent, - }); - const policyResumeSelection = preparePolicyPresetResumeSelection( - { policies }, - sandboxName, - { - recordedPolicyPresets, - disabledChannels: activeSandbox?.disabledChannels, - enabledChannels: policyMessagingChannels, - hermesToolGateways, - webSearchConfig, - webSearchSupported, + deps: { + loadSession: onboardSession.loadSession, + getActiveSandbox: (name) => registry.getSandbox(name), + mergePolicyMessagingChannels, + verifyCompatibleEndpointSandboxSmoke: (options) => + verifyCompatibleEndpointSandboxSmoke({ + ...options, + runOpenshell, + redact, + }), + preparePolicyPresetResumeSelection: (name, options) => + preparePolicyPresetResumeSelection({ policies }, name, options), + arePolicyPresetsApplied, + skippedStepMessage, + startRecordedStep, + setupPoliciesWithSelection, + updateSession: onboardSession.updateSession, + recordStepComplete, + toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters[0]), }, - ); - const recordedPolicyPresetsForSupport = policyResumeSelection.policyPresets; - const resumePolicies = - resume && - sandboxName && - !policyResumeSelection.recordedPolicyPresetsNeedReconcile && - !policyResumeSelection.disabledMessagingPolicyPresetApplied && - arePolicyPresetsApplied(sandboxName, recordedPolicyPresetsForSupport); - if (resumePolicies) { - skippedStepMessage("policies", recordedPolicyPresetsForSupport.join(", ")); - await recordStepComplete( - "policies", - toSessionUpdates({ - sandboxName, - provider, - model, - policyPresets: recordedPolicyPresetsForSupport, - }), - ); - } else { - await startRecordedStep("policies", { - sandboxName, - provider, - model, - policyPresets: recordedPolicyPresetsForSupport, - }); - const setupAppliedPolicyPresets = await setupPoliciesWithSelection(sandboxName, { - selectedPresets: - Array.isArray(recordedPolicyPresets) - ? recordedPolicyPresetsForSupport - : null, - enabledChannels: policyMessagingChannels, - disabledChannels: activeSandbox?.disabledChannels, - webSearchConfig, - provider, - webSearchSupported, - hermesToolGateways, - onSelection: (policyPresets) => { - onboardSession.updateSession((current: Session) => { - current.policyPresets = policyPresets; - return current; - }); - }, - }); - await recordStepComplete( - "policies", - toSessionUpdates({ - sandboxName, - provider, - model, - policyPresets: setupAppliedPolicyPresets, - }), - ); - } + }); + session = policiesResult.session; if (agent) { ensureAgentDashboardForward(sandboxName, agent); diff --git a/src/lib/onboard/machine/handlers/policies.test.ts b/src/lib/onboard/machine/handlers/policies.test.ts new file mode 100644 index 0000000000..3bb3c3477a --- /dev/null +++ b/src/lib/onboard/machine/handlers/policies.test.ts @@ -0,0 +1,197 @@ +// 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 { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; +import { handlePoliciesState, type PoliciesStateOptions } from "./policies"; + +type Agent = { name: string } | null; +type WebSearchConfig = { fetchEnabled: true }; + +function createDeps(overrides: Partial["deps"]> = {}) { + let session = createSession(); + const calls = { + load: vi.fn(() => session), + activeSandbox: vi.fn(() => ({ messagingChannels: ["telegram"], disabledChannels: null })), + mergeChannels: vi.fn( + ( + selected: string[], + recorded: string[], + active: string[] | null | undefined, + ) => (selected.length > 0 ? selected : active ?? recorded), + ), + smoke: vi.fn(), + prepareResume: vi.fn( + ( + _sandboxName: string, + options: Parameters["deps"]["preparePolicyPresetResumeSelection"]>[1], + ) => ({ + policyPresets: (options.recordedPolicyPresets ?? []).filter((name) => name !== "unsupported"), + recordedPolicyPresetsNeedReconcile: (options.recordedPolicyPresets ?? []).includes("unsupported"), + disabledMessagingPolicyPresetApplied: false, + }), + ), + appliedCheck: vi.fn(() => false), + skipped: vi.fn(), + startStep: vi.fn(async () => undefined), + setupPolicies: vi.fn(async () => ["npm"]), + updateSession: vi.fn((mutator: (value: Session) => Session | void) => { + session = mutator(session) ?? session; + return session; + }), + complete: vi.fn(async () => session), + }; + return { + calls, + deps: { + loadSession: calls.load, + getActiveSandbox: calls.activeSandbox, + mergePolicyMessagingChannels: calls.mergeChannels, + verifyCompatibleEndpointSandboxSmoke: calls.smoke, + preparePolicyPresetResumeSelection: calls.prepareResume, + arePolicyPresetsApplied: calls.appliedCheck, + skippedStepMessage: calls.skipped, + startRecordedStep: calls.startStep, + setupPoliciesWithSelection: calls.setupPolicies, + updateSession: calls.updateSession, + recordStepComplete: calls.complete, + toSessionUpdates: (updates: Record) => updates as SessionUpdates, + ...overrides, + }, + setSession(next: Session) { + session = next; + }, + getSession: () => session, + }; +} + +function baseOptions( + deps: PoliciesStateOptions["deps"], +): PoliciesStateOptions { + return { + resume: false, + sandboxName: "my-assistant", + provider: "provider", + model: "model", + endpointUrl: "https://example.com/v1", + credentialEnv: "NVIDIA_API_KEY", + selectedMessagingChannels: [], + webSearchConfig: null, + webSearchSupported: true, + hermesToolGateways: [], + agent: null, + deps, + }; +} + +describe("handlePoliciesState", () => { + it("runs compatible endpoint smoke before policy selection", async () => { + const { deps, calls } = createDeps(); + + await handlePoliciesState(baseOptions(deps)); + + expect(calls.smoke).toHaveBeenCalledWith({ + sandboxName: "my-assistant", + provider: "provider", + model: "model", + endpointUrl: "https://example.com/v1", + credentialEnv: "NVIDIA_API_KEY", + messagingChannels: ["telegram"], + agent: null, + }); + expect(calls.startStep).toHaveBeenCalledWith("policies", { + sandboxName: "my-assistant", + provider: "provider", + model: "model", + policyPresets: [], + }); + expect(calls.setupPolicies).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ + selectedPresets: null, + enabledChannels: ["telegram"], + provider: "provider", + webSearchSupported: true, + }), + ); + expect(calls.complete).toHaveBeenCalledWith( + "policies", + expect.objectContaining({ policyPresets: ["npm"] }), + ); + }); + + it("uses recorded messaging channels when no active selection exists", async () => { + const session = createSession({ messagingChannels: ["slack"] }); + const { deps, calls, setSession } = createDeps({ + getActiveSandbox: vi.fn(() => ({ messagingChannels: null, disabledChannels: null })), + }); + setSession(session); + + await handlePoliciesState(baseOptions(deps)); + + expect(calls.setupPolicies).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ enabledChannels: ["slack"] }), + ); + }); + + it("resumes policies when all recorded presets are already applied", async () => { + const session = createSession({ policyPresets: ["npm"] }); + const { deps, calls, setSession } = createDeps({ + arePolicyPresetsApplied: vi.fn(() => true), + }); + setSession(session); + + const result = await handlePoliciesState({ ...baseOptions(deps), resume: true }); + + expect(calls.skipped).toHaveBeenCalledWith("policies", "npm"); + expect(calls.setupPolicies).not.toHaveBeenCalled(); + expect(calls.complete).toHaveBeenCalledWith( + "policies", + expect.objectContaining({ policyPresets: ["npm"] }), + ); + expect(result.appliedPolicyPresets).toEqual(["npm"]); + }); + + it("reconciles unsupported recorded presets before interactive setup", async () => { + const session = createSession({ policyPresets: ["npm", "unsupported"] }); + const { deps, calls, setSession } = createDeps(); + setSession(session); + + await handlePoliciesState(baseOptions(deps)); + + expect(calls.prepareResume).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ recordedPolicyPresets: ["npm", "unsupported"] }), + ); + expect(calls.setupPolicies).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ selectedPresets: ["npm"] }), + ); + }); + + it("merges required Hermes tool gateway presets into recorded selections", async () => { + const session = createSession({ policyPresets: ["npm"] }); + const prepareResume = vi.fn((_sandboxName, options) => ({ + policyPresets: [...(options.recordedPolicyPresets ?? []), ...options.hermesToolGateways], + recordedPolicyPresetsNeedReconcile: false, + disabledMessagingPolicyPresetApplied: false, + })); + const { deps, calls, setSession } = createDeps({ + preparePolicyPresetResumeSelection: prepareResume, + }); + setSession(session); + + await handlePoliciesState({ ...baseOptions(deps), hermesToolGateways: ["github"] }); + + expect(prepareResume).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ hermesToolGateways: ["github"] }), + ); + expect(calls.setupPolicies).toHaveBeenCalledWith( + "my-assistant", + expect.objectContaining({ selectedPresets: ["npm", "github"] }), + ); + }); +}); diff --git a/src/lib/onboard/machine/handlers/policies.ts b/src/lib/onboard/machine/handlers/policies.ts new file mode 100644 index 0000000000..ff6bc960ba --- /dev/null +++ b/src/lib/onboard/machine/handlers/policies.ts @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Session, SessionUpdates } from "../../../state/onboard-session"; + +export interface PolicyPresetEntry { + name: string; + [key: string]: unknown; +} + +export interface ActiveSandboxPolicyState { + messagingChannels?: string[] | null; + disabledChannels?: string[] | null; +} + +export interface PolicyResumeSelection { + policyPresets: string[]; + recordedPolicyPresetsNeedReconcile: boolean; + disabledMessagingPolicyPresetApplied: boolean; +} + +export interface PoliciesStateOptions { + resume: boolean; + sandboxName: string; + provider: string; + model: string; + endpointUrl: string | null; + credentialEnv: string | null; + selectedMessagingChannels: string[]; + webSearchConfig: WebSearchConfig | null; + webSearchSupported: boolean; + hermesToolGateways: string[]; + agent: Agent; + deps: { + loadSession(): Session | null; + getActiveSandbox(sandboxName: string): ActiveSandboxPolicyState | null | undefined; + mergePolicyMessagingChannels( + selectedMessagingChannels: string[], + recordedMessagingChannels: string[], + activeMessagingChannels: string[] | null | undefined, + disabledChannels: string[] | null | undefined, + ): string[]; + verifyCompatibleEndpointSandboxSmoke(options: { + sandboxName: string; + provider: string; + model: string; + endpointUrl: string | null; + credentialEnv: string | null; + messagingChannels: string[]; + agent: Agent; + }): void; + preparePolicyPresetResumeSelection( + sandboxName: string, + options: { + recordedPolicyPresets: string[] | null; + disabledChannels: string[] | null | undefined; + enabledChannels: string[]; + hermesToolGateways: string[]; + webSearchConfig: WebSearchConfig | null; + webSearchSupported: boolean; + }, + ): PolicyResumeSelection; + arePolicyPresetsApplied(sandboxName: string, selectedPresets: string[]): boolean; + skippedStepMessage(stepName: string, detail?: string | null): void; + startRecordedStep( + stepName: string, + updates: { sandboxName: string; provider: string; model: string; policyPresets: string[] }, + ): Promise; + setupPoliciesWithSelection( + sandboxName: string, + options: { + selectedPresets: string[] | null; + enabledChannels: string[]; + disabledChannels?: string[] | null; + webSearchConfig: WebSearchConfig | null; + provider: string; + webSearchSupported: boolean; + hermesToolGateways: string[]; + onSelection: (policyPresets: string[]) => void; + }, + ): Promise; + updateSession(mutator: (session: Session) => Session | void): Session; + recordStepComplete(stepName: string, updates: SessionUpdates): Promise; + toSessionUpdates(updates: Record): SessionUpdates; + }; +} + +export interface PoliciesStateResult { + session: Session | null; + recordedMessagingChannels: string[]; + appliedPolicyPresets: string[]; +} + +export async function handlePoliciesState({ + resume, + sandboxName, + provider, + model, + endpointUrl, + credentialEnv, + selectedMessagingChannels, + webSearchConfig, + webSearchSupported, + hermesToolGateways, + agent, + deps, +}: PoliciesStateOptions): Promise { + const latestSession = deps.loadSession(); + const recordedPolicyPresets = Array.isArray(latestSession?.policyPresets) + ? latestSession.policyPresets + : null; + const recordedMessagingChannels = Array.isArray(latestSession?.messagingChannels) + ? latestSession.messagingChannels + : []; + const activeSandbox = deps.getActiveSandbox(sandboxName); + const policyMessagingChannels = deps.mergePolicyMessagingChannels( + selectedMessagingChannels, + recordedMessagingChannels, + activeSandbox?.messagingChannels, + activeSandbox?.disabledChannels, + ); + deps.verifyCompatibleEndpointSandboxSmoke({ + sandboxName, + provider, + model, + endpointUrl, + credentialEnv, + messagingChannels: policyMessagingChannels, + agent, + }); + + const policyResumeSelection = deps.preparePolicyPresetResumeSelection(sandboxName, { + recordedPolicyPresets, + disabledChannels: activeSandbox?.disabledChannels, + enabledChannels: policyMessagingChannels, + hermesToolGateways, + webSearchConfig, + webSearchSupported, + }); + const recordedPolicyPresetsForSupport = policyResumeSelection.policyPresets; + const resumePolicies = + resume && + !policyResumeSelection.recordedPolicyPresetsNeedReconcile && + !policyResumeSelection.disabledMessagingPolicyPresetApplied && + deps.arePolicyPresetsApplied(sandboxName, recordedPolicyPresetsForSupport); + + let appliedPolicyPresets = recordedPolicyPresetsForSupport; + let session: Session | null; + if (resumePolicies) { + deps.skippedStepMessage("policies", recordedPolicyPresetsForSupport.join(", ")); + session = await deps.recordStepComplete( + "policies", + deps.toSessionUpdates({ + sandboxName, + provider, + model, + policyPresets: recordedPolicyPresetsForSupport, + }), + ); + } else { + await deps.startRecordedStep("policies", { + sandboxName, + provider, + model, + policyPresets: recordedPolicyPresetsForSupport, + }); + appliedPolicyPresets = await deps.setupPoliciesWithSelection(sandboxName, { + selectedPresets: Array.isArray(recordedPolicyPresets) + ? recordedPolicyPresetsForSupport + : null, + enabledChannels: policyMessagingChannels, + disabledChannels: activeSandbox?.disabledChannels, + webSearchConfig, + provider, + webSearchSupported, + hermesToolGateways, + onSelection: (policyPresets) => { + deps.updateSession((current) => { + current.policyPresets = policyPresets; + return current; + }); + }, + }); + session = await deps.recordStepComplete( + "policies", + deps.toSessionUpdates({ sandboxName, provider, model, policyPresets: appliedPolicyPresets }), + ); + } + + return { session, recordedMessagingChannels, appliedPolicyPresets }; +}