Conversation
Open
… and code quality improvements Co-authored-by: alvagante <283804+alvagante@users.noreply.github.com>
Contributor
Author
|
Just as a heads up, I was blocked by some firewall rules while working on your feedback. Expand below for details. Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
Copilot
AI
changed the title
[WIP] [090] Add Proxmox provisioning and lifecycle management support
Fix review feedback: Proxmox API safety, type deduplication, accessibility, and code cleanup
Mar 14, 2026
alvagante
approved these changes
Mar 14, 2026
Contributor
There was a problem hiding this comment.
Pull request overview
Addresses prior review feedback by tightening Proxmox integration safety (plugin interface usage + TLS handling), deduplicating shared types, cleaning up inventory/linking code, and restoring several frontend accessibility semantics.
Changes:
- Backend: route-level Proxmox execution now uses properly-shaped
Actionobjects and avoids unsafe interface casts; SSE token handling reduces query-param leakage. - Backend: capability/type deduplication and provisioning capabilities now sourced from the Proxmox integration; node linking/aggregation cleanup.
- Frontend: restores/improves keyboard and semantic accessibility for dialogs, regions, and form labeling.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| package-lock.json | Bumps lockfile workspace versions to 0.9.0. |
| frontend/src/components/ManageTab.svelte | Confirmation dialog keyboard handling adjusted (Escape listener + backdrop tabindex). |
| frontend/src/components/GroupActionModal.svelte | Restores focusability of scrollable region for keyboard navigation. |
| frontend/src/components/ExecuteCommandForm.svelte | Improves semantic labeling via fieldset/legend. |
| frontend/src/components/AnsiblePlaybookInterface.svelte | Marks decorative SVG as hidden from assistive tech. |
| backend/test/integrations/IntegrationManager.test.ts | Updates assertions to validate sourceData/sources instead of non-existent .source. |
| backend/src/server.ts | Fixes Proxmox priority comment to match actual priority scheme. |
| backend/src/routes/streaming.ts | Only promotes query token to Authorization when header absent; deletes query token afterward. |
| backend/src/routes/inventory.ts | Uses getExecutionTool("proxmox") and correct Action shapes; adds vmid finite-number guard. |
| backend/src/routes/integrations/provisioning.ts | Removes duplicated capability types; returns Proxmox capabilities from integration method. |
| backend/src/integrations/types.ts | Adds optional validation to CapabilityParameter. |
| backend/src/integrations/proxmox/types.ts | Removes duplicate ProvisioningCapability and re-exports shared type. |
| backend/src/integrations/proxmox/ProxmoxClient.ts | Removes process-wide TLS disable; logs operator guidance instead. |
| backend/src/integrations/NodeLinkingService.ts | Cleans linking logic (primary URI selection, unused var removal). |
| backend/src/integrations/IntegrationManager.ts | Collapses duplicate JSDoc and removes dead helpers in favor of NodeLinkingService. |
| .kiro/steering/security-best-practices.md | Fixes typo/wording around allowlisting secrets. |
Comment on lines
+48
to
62
| // Configure SSL behavior | ||
| // NOTE: Setting NODE_TLS_REJECT_UNAUTHORIZED is process-wide and would disable TLS verification | ||
| // for ALL HTTPS traffic, not just Proxmox. Instead, log a warning to guide the operator. | ||
| // Per-client TLS bypass via a custom HTTPS agent/dispatcher is the correct solution. | ||
| if (config.ssl && config.ssl.rejectUnauthorized === false) { | ||
| process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; | ||
| this.logger.debug("Disabled TLS certificate verification for Proxmox", { | ||
| component: "ProxmoxClient", | ||
| operation: "constructor", | ||
| }); | ||
| this.logger.warn( | ||
| "Proxmox ssl.rejectUnauthorized=false is set, but per-client TLS bypass is not yet implemented. " + | ||
| "Proxmox connections will use the default TLS verification. " + | ||
| "Configure a trusted CA certificate (ssl.ca) to connect to Proxmox with self-signed certs.", | ||
| { | ||
| component: "ProxmoxClient", | ||
| operation: "constructor", | ||
| } | ||
| ); | ||
| } |
Comment on lines
+915
to
+928
| /** | ||
| * Deduplicate and link nodes by matching identifiers. | ||
| * | ||
| * @param nodes - Array of nodes potentially with duplicates | ||
| * @returns Deduplicated array of nodes | ||
| * When multiple sources provide the same node (matched by identifiers like certname, | ||
| * hostname, or URI), merge them into a single node entry with all sources tracked. | ||
| * The node data is taken from the highest priority source, but all sources and URIs | ||
| * are recorded in sourceData. | ||
| * | ||
| * @param nodes - Array of nodes from all sources | ||
| * @returns Deduplicated and linked array of nodes with source attribution | ||
| */ | ||
| /** | ||
| * Deduplicate and link nodes by ID | ||
| * | ||
| * When multiple sources provide the same node (by ID), merge them into a single | ||
| * node entry with all sources tracked. This matches the behavior of group linking. | ||
| * The node data is taken from the highest priority source, but all sources are | ||
| * recorded in the sources array. | ||
| * | ||
| * @param nodes - Array of nodes from all sources | ||
| * @returns Deduplicated and linked array of nodes with source attribution | ||
| */ | ||
| /** | ||
| * Deduplicate and link nodes by ID | ||
| * | ||
| * When multiple sources provide the same node (by ID), merge them into a single | ||
| * node entry with all sources tracked. This matches the behavior of group linking. | ||
| * The node data is taken from the highest priority source, but all sources are | ||
| * recorded in the sources array. | ||
| * | ||
| * @param nodes - Array of nodes from all sources | ||
| * @returns Deduplicated and linked array of nodes with source attribution | ||
| */ | ||
| /** | ||
| * Deduplicate and link nodes by matching identifiers | ||
| * | ||
| * When multiple sources provide the same node (matched by identifiers like certname, | ||
| * hostname, URI), merge them into a single node entry with all sources tracked. | ||
| * This matches the behavior of group linking. The node data is taken from the highest | ||
| * priority source, but all sources and URIs are recorded. | ||
| * | ||
| * @param nodes - Array of nodes from all sources | ||
| * @returns Deduplicated and linked array of nodes with source attribution | ||
| */ | ||
| private deduplicateNodes(nodes: Node[]): LinkedNode[] { | ||
| // Use NodeLinkingService to link nodes by identifiers | ||
| // This already handles source-specific data correctly | ||
| return this.nodeLinkingService.linkNodes(nodes); | ||
| } | ||
|
|
||
| /** | ||
| * Check if a node matches a linked node by comparing identifiers | ||
| * | ||
| * @param node - Node to check | ||
| * @param linkedNode - Linked node to match against | ||
| * @returns True if nodes match | ||
| * @private | ||
| */ | ||
| private nodesMatch(node: Node, linkedNode: LinkedNode): boolean { | ||
| const nodeIdentifiers = this.extractNodeIdentifiers(node); | ||
| const linkedIdentifiers = this.extractNodeIdentifiers(linkedNode); | ||
|
|
||
| // Check if any identifiers match | ||
| for (const id of nodeIdentifiers) { | ||
| if (linkedIdentifiers.includes(id)) { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Extract all possible identifiers from a node | ||
| * | ||
| * @param node - Node to extract identifiers from | ||
| * @returns Array of identifiers (normalized to lowercase) | ||
| * @private | ||
| */ | ||
| private extractNodeIdentifiers(node: Node): string[] { | ||
| const identifiers: string[] = []; | ||
|
|
||
| // Add node ID | ||
| if (node.id) { | ||
| identifiers.push(node.id.toLowerCase()); | ||
| } | ||
|
|
||
| // Add node name (certname) | ||
| // Skip empty names to prevent incorrect linking | ||
| if (node.name && node.name.trim() !== "") { | ||
| // Normalize hostname: trim, lowercase, remove domain suffix | ||
| const normalizedName = node.name.trim().toLowerCase(); | ||
| identifiers.push(normalizedName); | ||
|
|
||
| // Also add short hostname (without domain) | ||
| const shortName = normalizedName.split('.')[0]; | ||
| if (shortName !== normalizedName) { | ||
| identifiers.push(shortName); | ||
| } | ||
| } | ||
|
|
||
| // Add URI hostname (extract from URI) | ||
| // Skip Proxmox URIs as they use format proxmox://node/vmid where 'node' is not unique per VM | ||
| if (node.uri && !node.uri.startsWith("proxmox://")) { | ||
| try { | ||
| // Extract hostname from URI | ||
| // URIs can be in formats like: | ||
| // - ssh://hostname | ||
| // - hostname | ||
| // - hostname:port | ||
| const uriParts = node.uri.split("://"); | ||
| const hostPart = uriParts.length > 1 ? uriParts[1] : uriParts[0]; | ||
| const hostname = hostPart.split(":")[0].split("/")[0].trim().toLowerCase(); | ||
|
|
||
| if (hostname) { | ||
| identifiers.push(hostname); | ||
|
|
||
| // Also add short hostname | ||
| const shortHostname = hostname.split('.')[0]; | ||
| if (shortHostname !== hostname) { | ||
| identifiers.push(shortHostname); | ||
| } | ||
| } | ||
| } catch { | ||
| // Ignore URI parsing errors | ||
| } | ||
| } | ||
|
|
||
| // Add hostname from config if available | ||
| const nodeConfig = node.config as { hostname?: string } | undefined; | ||
| if (nodeConfig?.hostname) { | ||
| const normalizedHostname = nodeConfig.hostname.trim().toLowerCase(); | ||
| identifiers.push(normalizedHostname); | ||
|
|
||
| // Also add short hostname | ||
| const shortHostname = normalizedHostname.split('.')[0]; | ||
| if (shortHostname !== normalizedHostname) { | ||
| identifiers.push(shortHostname); | ||
| } | ||
| } | ||
|
|
||
| // Remove duplicates | ||
| return Array.from(new Set(identifiers)); | ||
| } | ||
| private deduplicateNodes(nodes: Node[]): LinkedNode[] { | ||
| return this.nodeLinkingService.linkNodes(nodes); | ||
| } |
Comment on lines
+915
to
+928
| /** | ||
| * Deduplicate and link nodes by matching identifiers. | ||
| * | ||
| * @param nodes - Array of nodes potentially with duplicates | ||
| * @returns Deduplicated array of nodes | ||
| * When multiple sources provide the same node (matched by identifiers like certname, | ||
| * hostname, or URI), merge them into a single node entry with all sources tracked. | ||
| * The node data is taken from the highest priority source, but all sources and URIs | ||
| * are recorded in sourceData. | ||
| * | ||
| * @param nodes - Array of nodes from all sources | ||
| * @returns Deduplicated and linked array of nodes with source attribution | ||
| */ | ||
| /** | ||
| * Deduplicate and link nodes by ID | ||
| * | ||
| * When multiple sources provide the same node (by ID), merge them into a single | ||
| * node entry with all sources tracked. This matches the behavior of group linking. | ||
| * The node data is taken from the highest priority source, but all sources are | ||
| * recorded in the sources array. | ||
| * | ||
| * @param nodes - Array of nodes from all sources | ||
| * @returns Deduplicated and linked array of nodes with source attribution | ||
| */ | ||
| /** | ||
| * Deduplicate and link nodes by ID | ||
| * | ||
| * When multiple sources provide the same node (by ID), merge them into a single | ||
| * node entry with all sources tracked. This matches the behavior of group linking. | ||
| * The node data is taken from the highest priority source, but all sources are | ||
| * recorded in the sources array. | ||
| * | ||
| * @param nodes - Array of nodes from all sources | ||
| * @returns Deduplicated and linked array of nodes with source attribution | ||
| */ | ||
| /** | ||
| * Deduplicate and link nodes by matching identifiers | ||
| * | ||
| * When multiple sources provide the same node (matched by identifiers like certname, | ||
| * hostname, URI), merge them into a single node entry with all sources tracked. | ||
| * This matches the behavior of group linking. The node data is taken from the highest | ||
| * priority source, but all sources and URIs are recorded. | ||
| * | ||
| * @param nodes - Array of nodes from all sources | ||
| * @returns Deduplicated and linked array of nodes with source attribution | ||
| */ | ||
| private deduplicateNodes(nodes: Node[]): LinkedNode[] { | ||
| // Use NodeLinkingService to link nodes by identifiers | ||
| // This already handles source-specific data correctly | ||
| return this.nodeLinkingService.linkNodes(nodes); | ||
| } | ||
|
|
||
| /** | ||
| * Check if a node matches a linked node by comparing identifiers | ||
| * | ||
| * @param node - Node to check | ||
| * @param linkedNode - Linked node to match against | ||
| * @returns True if nodes match | ||
| * @private | ||
| */ | ||
| private nodesMatch(node: Node, linkedNode: LinkedNode): boolean { | ||
| const nodeIdentifiers = this.extractNodeIdentifiers(node); | ||
| const linkedIdentifiers = this.extractNodeIdentifiers(linkedNode); | ||
|
|
||
| // Check if any identifiers match | ||
| for (const id of nodeIdentifiers) { | ||
| if (linkedIdentifiers.includes(id)) { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Extract all possible identifiers from a node | ||
| * | ||
| * @param node - Node to extract identifiers from | ||
| * @returns Array of identifiers (normalized to lowercase) | ||
| * @private | ||
| */ | ||
| private extractNodeIdentifiers(node: Node): string[] { | ||
| const identifiers: string[] = []; | ||
|
|
||
| // Add node ID | ||
| if (node.id) { | ||
| identifiers.push(node.id.toLowerCase()); | ||
| } | ||
|
|
||
| // Add node name (certname) | ||
| // Skip empty names to prevent incorrect linking | ||
| if (node.name && node.name.trim() !== "") { | ||
| // Normalize hostname: trim, lowercase, remove domain suffix | ||
| const normalizedName = node.name.trim().toLowerCase(); | ||
| identifiers.push(normalizedName); | ||
|
|
||
| // Also add short hostname (without domain) | ||
| const shortName = normalizedName.split('.')[0]; | ||
| if (shortName !== normalizedName) { | ||
| identifiers.push(shortName); | ||
| } | ||
| } | ||
|
|
||
| // Add URI hostname (extract from URI) | ||
| // Skip Proxmox URIs as they use format proxmox://node/vmid where 'node' is not unique per VM | ||
| if (node.uri && !node.uri.startsWith("proxmox://")) { | ||
| try { | ||
| // Extract hostname from URI | ||
| // URIs can be in formats like: | ||
| // - ssh://hostname | ||
| // - hostname | ||
| // - hostname:port | ||
| const uriParts = node.uri.split("://"); | ||
| const hostPart = uriParts.length > 1 ? uriParts[1] : uriParts[0]; | ||
| const hostname = hostPart.split(":")[0].split("/")[0].trim().toLowerCase(); | ||
|
|
||
| if (hostname) { | ||
| identifiers.push(hostname); | ||
|
|
||
| // Also add short hostname | ||
| const shortHostname = hostname.split('.')[0]; | ||
| if (shortHostname !== hostname) { | ||
| identifiers.push(shortHostname); | ||
| } | ||
| } | ||
| } catch { | ||
| // Ignore URI parsing errors | ||
| } | ||
| } | ||
|
|
||
| // Add hostname from config if available | ||
| const nodeConfig = node.config as { hostname?: string } | undefined; | ||
| if (nodeConfig?.hostname) { | ||
| const normalizedHostname = nodeConfig.hostname.trim().toLowerCase(); | ||
| identifiers.push(normalizedHostname); | ||
|
|
||
| // Also add short hostname | ||
| const shortHostname = normalizedHostname.split('.')[0]; | ||
| if (shortHostname !== normalizedHostname) { | ||
| identifiers.push(shortHostname); | ||
| } | ||
| } | ||
|
|
||
| // Remove duplicates | ||
| return Array.from(new Set(identifiers)); | ||
| } | ||
| private deduplicateNodes(nodes: Node[]): LinkedNode[] { | ||
| return this.nodeLinkingService.linkNodes(nodes); | ||
| } |
Comment on lines
333
to
339
| <div | ||
| class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity dark:bg-gray-900 dark:bg-opacity-75" | ||
| onclick={cancelConfirmation} | ||
| onkeydown={(e) => e.key === 'Escape' && cancelConfirmation()} | ||
| role="button" | ||
| tabindex="-1" | ||
| tabindex="0" | ||
| aria-label="Close dialog" | ||
| ></div> |
Comment on lines
1181
to
1199
| @@ -1190,14 +1191,11 @@ export function createInventoryRouter( | |||
| return; | |||
| } | |||
|
|
|||
| // Execute the action | |||
| const proxmoxService = proxmoxSource as unknown as { | |||
| executeAction: (action: { action: string; target: string }) => Promise<unknown>; | |||
| }; | |||
|
|
|||
| const result = await proxmoxService.executeAction({ | |||
| action: body.action, | |||
| // Execute the action with a properly-shaped Action object | |||
| const result = await proxmoxTool.executeAction({ | |||
| type: "task", | |||
| target: nodeId, | |||
| action: body.action, | |||
| }); | |||
Comment on lines
1310
to
+1357
| @@ -1339,12 +1337,24 @@ export function createInventoryRouter( | |||
| const node = parts[1]; | |||
| const vmid = parseInt(parts[2], 10); | |||
|
|
|||
| // Execute the destroy action | |||
| const proxmoxService = proxmoxSource as unknown as { | |||
| destroyGuest: (node: string, vmid: number) => Promise<unknown>; | |||
| }; | |||
| if (!Number.isFinite(vmid)) { | |||
| const errorResponse = { | |||
| error: { | |||
| code: "INVALID_NODE_ID", | |||
| message: "Invalid Proxmox node ID: vmid is not a valid number", | |||
| }, | |||
| }; | |||
|
|
|||
| res.status(400).json(errorResponse); | |||
| return; | |||
| } | |||
|
|
|||
| const result = await proxmoxService.destroyGuest(node, vmid); | |||
| const result = await proxmoxTool.executeAction({ | |||
| type: "task", | |||
| target: nodeId, | |||
| action: "destroy_vm", | |||
| parameters: { node, vmid }, | |||
| }); | |||
Comment on lines
45
to
69
| // Check Proxmox integration | ||
| const proxmox = integrationManager.getExecutionTool("proxmox") as ProxmoxIntegration | null; | ||
|
|
||
| if (proxmox) { | ||
| // Determine integration status based on health check | ||
| let status: 'connected' | 'degraded' | 'not_configured' = 'not_configured'; | ||
| const healthCheck = proxmox.getLastHealthCheck(); | ||
|
|
||
| if (healthCheck) { | ||
| if (healthCheck.healthy) { | ||
| status = 'connected'; | ||
| } else if (healthCheck.message?.includes('not initialized') || healthCheck.message?.includes('disabled')) { | ||
| status = 'not_configured'; | ||
| } else { | ||
| status = 'degraded'; | ||
| } | ||
| } | ||
|
|
||
| const proxmoxIntegration: ProvisioningIntegration = { | ||
| name: "proxmox", | ||
| displayName: "Proxmox VE", | ||
| type: "virtualization", | ||
| status, | ||
| capabilities: [ | ||
| { | ||
| name: "create_vm", | ||
| description: "Create a new virtual machine", | ||
| operation: "create", | ||
| parameters: [ | ||
| { name: "vmid", type: "number", required: true, description: "Unique VM identifier", validation: { min: 100, max: 999999999 } }, | ||
| { name: "name", type: "string", required: true, description: "VM name", validation: { max: 50 } }, | ||
| { name: "node", type: "string", required: true, description: "Proxmox node name", validation: { max: 20 } }, | ||
| { name: "cores", type: "number", required: false, description: "Number of CPU cores", validation: { min: 1, max: 128 } }, | ||
| { name: "memory", type: "number", required: false, description: "Memory in MB", validation: { min: 16 } }, | ||
| { name: "sockets", type: "number", required: false, description: "Number of CPU sockets", validation: { min: 1, max: 4 } }, | ||
| { name: "cpu", type: "string", required: false, description: "CPU type" }, | ||
| { name: "scsi0", type: "string", required: false, description: "SCSI disk configuration" }, | ||
| { name: "ide2", type: "string", required: false, description: "IDE device configuration" }, | ||
| { name: "net0", type: "string", required: false, description: "Network interface configuration" }, | ||
| { name: "ostype", type: "string", required: false, description: "Operating system type" }, | ||
| ], | ||
| }, | ||
| { | ||
| name: "create_lxc", | ||
| description: "Create a new LXC container", | ||
| operation: "create", | ||
| parameters: [ | ||
| { name: "vmid", type: "number", required: true, description: "Unique container identifier", validation: { min: 100, max: 999999999 } }, | ||
| { name: "hostname", type: "string", required: true, description: "Container hostname", validation: { max: 50 } }, | ||
| { name: "node", type: "string", required: true, description: "Proxmox node name", validation: { max: 20 } }, | ||
| { name: "ostemplate", type: "string", required: true, description: "OS template name" }, | ||
| { name: "cores", type: "number", required: false, description: "Number of CPU cores", validation: { min: 1, max: 128 } }, | ||
| { name: "memory", type: "number", required: false, description: "Memory in MB", validation: { min: 16 } }, | ||
| { name: "rootfs", type: "string", required: false, description: "Root filesystem configuration" }, | ||
| { name: "net0", type: "string", required: false, description: "Network interface configuration" }, | ||
| { name: "password", type: "string", required: false, description: "Root password" }, | ||
| ], | ||
| }, | ||
| ], | ||
| capabilities: proxmox.listProvisioningCapabilities(), | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Several issues flagged in review: incorrect plugin interface casting in node lifecycle/destroy routes, process-wide TLS mutation, hardcoded provisioning capabilities, duplicated types, stale dead code, and accessibility regressions.
Backend — API Safety
executeActionnow retrievesgetExecutionTool("proxmox")and passes a correctly-typedAction({ type: "task", target, action }). Destroy route usesexecuteAction({ action: "destroy_vm", parameters: { node, vmid } })— no more unsafe casts toInformationSourcePlugin.Number.isFinite(vmid)guard;parseInton a malformed node ID silently producedNaNbefore.Authorizationheader when no header is already present; token deleted fromreq.queryto reduce log leakage.process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'(process-wide, affects all HTTPS). Replaced with a warning log directing operators to configure a CA cert; per-client TLS bypass noted as future work.Backend — Types & Code Quality
ProvisioningCapability; re-exports the shared definition fromintegrations/types.ts. Added optionalvalidationfield to sharedCapabilityParameter.proxmox.listProvisioningCapabilities()instead of a hardcoded inline list that would silently drift.deduplicateNodesto one; removed deadnodesMatchandextractNodeIdentifiershelpers (superseded byNodeLinkingService).nodeSourcevariable. Fixedurifield: was joining all source URIs with", "into a single string — now keeps the primary URI; per-source URIs are already insourceData.Frontend — Accessibility
tabindex="0"on the scrollablerole="region".tabindex="-1"→tabindex="0". Escape-to-close moved from anonkeydownon the unfocusable backdrop to a$effectwindow-level listener (active only while dialog is open).<div role="group" aria-label>to<fieldset><legend>for proper semantic labeling.aria-hidden="true" focusable="false".Tests & Docs
IntegrationManagertest that asserted.source(singular) onLinkedNode— that property is never set; updated to assertsourceDataentries andsourcesarray.server.ts(was "Puppetserver (8)"; actual scheme is Bolt/PuppetDB=10, Proxmox=7, Hiera=6).catched→caughtinsecurity-best-practices.md.🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.