diff --git a/CHANGELOG.md b/CHANGELOG.md index 18978165..8c98dbf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,9 +24,37 @@ Planned version: **1.5.4**. own session file, so profiles such as `prod.a` and `prod_a` no longer share one login (#620). - A `matches` condition with an invalid pattern, a regex timeout or an input longer than 1,048,576 characters fails the run and names the edge, instead of counting as "no match" (#680). +- Two active nodes can no longer share an output variable, regardless of case: saving, importing + or an MCP change with such a pair is rejected. A disabled node is ignored, so a disabled copy can + no longer take over the alias of the active one (#703). ### Fixed +- **Designer** + - Renaming an output variable updates every reference to it: templates in any field, a dynamic + target machine or credential, breakpoint conditions, decision cases and edge conditions (#689). + - Copying, pasting and duplicating give each copy its own output variable (`name_2`) and rewrite + the references inside the copy, so it no longer takes over the original's bindings (#703). + - Inserting an activity on a conditional edge keeps the condition in front of the new activity, + so the branch still runs only when the condition holds (#693). + - Deleting an edge from the command palette marks the workflow as changed and can be undone + (#694). + - Undo right after typing in a property field restores the value from before the edit (#595). + - A save, lock change, publish or run that finishes after you switched to another workflow no + longer acts on the workflow you opened in the meantime (#692). + - Undo and a restored draft also update open property panels: an edge switches back to its + expression, an agent's host list shows the restored values (#695). + - Text-file delete can switch to line-range and pattern mode again (#601). + - `waitForCondition` in `portOpen` mode saves the default port 443 it shows, and a probe without + host or valid port is flagged (#702). + - `scheduledTask` registration accepts only built-in or managed service accounts and no longer + suggests ordinary domain users with delegation (#705). + - The step details of a past run label the workflow's current configuration as such and show the + target machine the run actually used (#603). +- **Management pages and settings** + - Saving credentials, machines, users, global variables, maintenance windows, custom activities, + alerting rules and policies or a settings section locks the form until the server answers. A + late answer no longer closes or resets a record opened in the meantime (#690). - **Running workflows** - A breakpoint condition can use the run's inputs (`{{manual.*}}`) (#685). - Input values that look like `{{…}}` stay plain text and no longer fail the step as unresolved @@ -52,7 +80,8 @@ Planned version: **1.5.4**. - The Windows Update health example applies `freshnessDays` (#734). The temp-file cleanup example writes a new archive per run and keeps the earlier ones (#723). The README showcase reports a failed probe instead of success (#724). The tech demo uses one name for its child - workflow (#722). + workflow (#722). The SCCM/AD example of the browser demo is valid PowerShell again, and CI + parses every demo fixture script (#698). - The `powerManagement` test moves to the invasive set and needs a disposable host, because `abort` cancels a pending host restart (#720). Acknowledgement files accept only GUID correlation IDs (#721). Verification checks every run in the window and fails when required @@ -66,6 +95,8 @@ Planned version: **1.5.4**. ### Security +- Copying nodes to the clipboard also masks recognised secrets in edge conditions and in lists, not + only in node fields (#574). - The CLI, the MCP server and the Python admin scripts under `scripts/` no longer follow redirects with the login token, so the token is never sent to another host (#711). - Logging out with the CLI also ends the MCP server's session right away (#712). diff --git a/scripts/Test-ExampleWorkflowScripts.ps1 b/scripts/Test-ExampleWorkflowScripts.ps1 index 973252c4..86b05789 100644 --- a/scripts/Test-ExampleWorkflowScripts.ps1 +++ b/scripts/Test-ExampleWorkflowScripts.ps1 @@ -1,7 +1,8 @@ #requires -Version 5.1 # Runs the scripts of the shipped example workflows against synthetic evidence: the temp # cleanup keeps every run's archive, the host health check alerts on faults and missing -# evidence, and the Windows Update check applies freshnessDays. +# evidence, and the Windows Update check applies freshnessDays. The scripts of the browser +# demo fixtures are parsed only, never run. [CmdletBinding()] param() Set-StrictMode -Version 3.0 @@ -159,6 +160,22 @@ try { $fresh = (Invoke-StepScript -Script $tailScript -Capture 'updateFresh').updateFresh Assert-Example ($fresh -eq $case.Fresh) "Hotfix ages '$($case.Ages -join ',')' ($($script:hotfixMode)) gave updateFresh '$fresh' instead of '$($case.Fresh)'." } + + # --- browser demo fixtures: every runScript parses (they are exportable) -------------- + $fixtures = Join-Path $PSScriptRoot '..\src\nodepilot-ui\demo\seed\fixtures' + foreach ($file in Get-ChildItem -LiteralPath $fixtures -Filter '*.workflow.json') { + $definition = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 | ConvertFrom-Json + $scripts = @($definition.nodes | Where-Object { + $_.PSObject.Properties['data'] -and $_.data.PSObject.Properties['activityType'] -and $_.data.activityType -eq 'runScript' }) + Assert-Example ($scripts.Count -gt 0) "$($file.Name) has no runScript step to parse." + foreach ($node in $scripts) { + $tokens = $null + $parseErrors = $null + $null = [System.Management.Automation.Language.Parser]::ParseInput([string]$node.data.config.script, [ref]$tokens, [ref]$parseErrors) + $detail = $(if ($parseErrors.Count -gt 0) { $parseErrors[0].Message } else { '' }) + Assert-Example ($parseErrors.Count -eq 0) "$($file.Name) step '$($node.id)' does not parse: $detail" + } + } } finally { Remove-Item -LiteralPath $sandbox -Recurse -Force } diff --git a/src/NodePilot.Core/WorkflowDefinitions/WorkflowAnalyzer.cs b/src/NodePilot.Core/WorkflowDefinitions/WorkflowAnalyzer.cs index 15816eca..668c6266 100644 --- a/src/NodePilot.Core/WorkflowDefinitions/WorkflowAnalyzer.cs +++ b/src/NodePilot.Core/WorkflowDefinitions/WorkflowAnalyzer.cs @@ -69,7 +69,6 @@ public static AnalysisResult Analyze(JsonElement definition) if (FindCycle(doc.Adjacency) is { Count: > 0 } cycle) findings.Add(new Finding("error", "cycle", null, $"Cycle detected: {string.Join(" → ", cycle)} → {cycle[0]}. The engine has no inDegree fallback; cyclic graphs Fail.")); - AddDuplicateOutputVariableFindings(doc, findings); AddDuplicatePublishedParamFindings(doc, findings); AddUnresolvedReferenceFindings(doc, definition, findings); @@ -278,30 +277,6 @@ private static void AddUnresolvedReferenceFindings( } } - private static void AddDuplicateOutputVariableFindings(WorkflowDefinitionDocument doc, List findings) - { - var seen = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var node in doc.Nodes) - { - if (doc.DisabledNodeIds.Contains(node.Id)) continue; - if (IsAnnotation(node.Type)) continue; - var outputVariable = node.Data.OutputVariable; - if (string.IsNullOrWhiteSpace(outputVariable)) continue; - - if (seen.TryGetValue(outputVariable, out var firstNodeId)) - { - findings.Add(new Finding( - "error", - "dup-output-variable", - node.Id, - $"outputVariable '{outputVariable}' is already used by node '{firstNodeId}' — downstream references {{{{{outputVariable}.output}}}} are ambiguous.")); - } - else - { - seen[outputVariable] = node.Id; - } - } - } private static void AddStartJobRunspaceFindings(WorkflowDefinitionDocument doc, List findings) { diff --git a/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionDocument.cs b/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionDocument.cs index 06b9b4f9..7d0fbb4f 100644 --- a/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionDocument.cs +++ b/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionDocument.cs @@ -192,8 +192,12 @@ public static Dictionary BuildOutputVariableAliasMap(IReadOnlyLi var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var node in allNodes) { - if (node.Data.OutputVariable is { Length: > 0 } variable && variable != node.Id) - dict[variable] = node.Id; + if (node.Data.Disabled) continue; + if (node.Data.OutputVariable is { Length: > 0 } variable) + { + if (!dict.TryAdd(variable, node.Id)) + throw new InvalidOperationException($"Duplicate active output variable '{variable}'."); + } } return dict; diff --git a/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionStructuralValidator.cs b/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionStructuralValidator.cs index 335f1c13..3e05d124 100644 --- a/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionStructuralValidator.cs +++ b/src/NodePilot.Core/WorkflowDefinitions/WorkflowDefinitionStructuralValidator.cs @@ -18,7 +18,7 @@ public static WorkflowDefinitionValidationResult Validate(JsonElement definition return WorkflowDefinitionValidationResult.Invalid("root must be a JSON object"); var nodeIds = new HashSet(StringComparer.Ordinal); - var outputVariables = new HashSet(StringComparer.Ordinal); + var outputVariables = new HashSet(StringComparer.OrdinalIgnoreCase); var activityTypeByNodeId = new Dictionary(StringComparer.Ordinal); if (definition.TryGetProperty("nodes", out var nodes)) { @@ -162,8 +162,11 @@ private static WorkflowDefinitionValidationResult ValidateNode( return WorkflowDefinitionValidationResult.Invalid(error!); } - if (data.TryGetProperty("outputVariable", out var outputVariable) && IsNonEmptyString(outputVariable)) - outputVariables.Add(outputVariable.GetString()!); + var disabled = data.TryGetProperty("disabled", out var disabledValue) && disabledValue.ValueKind == JsonValueKind.True; + if (!disabled && data.TryGetProperty("outputVariable", out var outputVariable) && IsNonEmptyString(outputVariable) + && !outputVariables.Add(outputVariable.GetString()!)) + return WorkflowDefinitionValidationResult.Invalid( + $"duplicate active output variable '{outputVariable.GetString()}'", "dup-output-variable", id); return WorkflowDefinitionValidationResult.Valid; } diff --git a/src/nodepilot-docs-ui/content/de/activities-reference.md b/src/nodepilot-docs-ui/content/de/activities-reference.md index df81d8fe..8570ef16 100644 --- a/src/nodepilot-docs-ui/content/de/activities-reference.md +++ b/src/nodepilot-docs-ui/content/de/activities-reference.md @@ -179,7 +179,7 @@ Bei Shutdown und Restart ist `force` standardmäßig `true`. Mit `force=false` m **Remote.** Erfordert meist Admin auf dem Ziel. -- **Config:** `action` (get/start/stop/enable/disable/unregister/register, default `get`), `taskName`, `taskPath` (default `\`). Register-only: `program`, `arguments`, `workingDirectory`, `triggerType` (once/daily/weekly/atLogon/atStartup), `startTime`, `daysOfWeek[]`, `weeksInterval`, `daysInterval`, `runAsUser` (default SYSTEM), `runLevel` (limited/highest), `description`, `force` +- **Config:** `action` (get/start/stop/enable/disable/unregister/register, default `get`), `taskName`, `taskPath` (default `\`). Register-only: `program`, `arguments`, `workingDirectory`, `triggerType` (once/daily/weekly/atLogon/atStartup), `startTime`, `daysOfWeek[]`, `weeksInterval`, `daysInterval`, `runAsUser` (Default SYSTEM; ein eingebautes Konto oder ein Managed Service Account wie `DOMAIN\gmsa$`, kein normaler Domänenbenutzer), `runLevel` (limited/highest), `description`, `force` - **Outputs:** `param.taskName`, `param.state`, `param.lastRunTime`, `param.lastTaskResult`, `param.nextRunTime` - **Provider-Fallback:** Aktionen auf vorhandenen Tasks verwenden zuerst die PowerShell-Cmdlets. Nur wenn der ScheduledTasks-CIM-Provider mit `0x80041318` fehlschlägt, greift die Activity innerhalb derselben lokalen/WinRM-PowerShell-Session auf die Task-Scheduler-Automation-API zurück. `register` bleibt ausschließlich Cmdlet-basiert. diff --git a/src/nodepilot-docs-ui/content/en/activities-reference.md b/src/nodepilot-docs-ui/content/en/activities-reference.md index b053f121..56606e20 100644 --- a/src/nodepilot-docs-ui/content/en/activities-reference.md +++ b/src/nodepilot-docs-ui/content/en/activities-reference.md @@ -174,7 +174,7 @@ For shutdown and restart, `force` defaults to `true`. With `force=false`, `delay **Remote.** Usually requires admin rights on the target. -- **Config:** `action` (get/start/stop/enable/disable/unregister/register, default `get`), `taskName`, `taskPath` (default `\`). Register only: `program`, `arguments`, `workingDirectory`, `triggerType` (once/daily/weekly/atLogon/atStartup), `startTime`, `daysOfWeek[]`, `weeksInterval`, `daysInterval`, `runAsUser` (default SYSTEM), `runLevel` (limited/highest), `description`, `force` +- **Config:** `action` (get/start/stop/enable/disable/unregister/register, default `get`), `taskName`, `taskPath` (default `\`). Register only: `program`, `arguments`, `workingDirectory`, `triggerType` (once/daily/weekly/atLogon/atStartup), `startTime`, `daysOfWeek[]`, `weeksInterval`, `daysInterval`, `runAsUser` (default SYSTEM; a built-in account or a managed service account such as `DOMAIN\gmsa$`, not an ordinary domain user), `runLevel` (limited/highest), `description`, `force` - **Outputs:** `param.taskName`, `param.state`, `param.lastRunTime`, `param.lastTaskResult`, `param.nextRunTime` - **Provider fallback:** actions on existing tasks use the PowerShell cmdlets first. Only if the ScheduledTasks CIM provider fails with `0x80041318` does the activity fall back to the Task Scheduler automation API within the same local/WinRM PowerShell session. `register` stays cmdlet-based exclusively. diff --git a/src/nodepilot-ui/CLAUDE.md b/src/nodepilot-ui/CLAUDE.md index 532a62bf..921610b6 100644 --- a/src/nodepilot-ui/CLAUDE.md +++ b/src/nodepilot-ui/CLAUDE.md @@ -13,6 +13,9 @@ Applies to `src/nodepilot-ui/`. Project-wide rules are in the root `CLAUDE.md`. - UI strings via `react-i18next` (namespaces per `src/i18n/locales/{de,en}/`, default DE) — new visible strings in **both** languages. - **Colours always via design tokens/CSS variables** — never hardcode Tailwind colour literals (`text-gray-900`, `bg-white` break the dark skins). Native `