mcp-pacemaker 2.0.2 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +171 -1
  2. package/CONTRIBUTING.md +46 -13
  3. package/README.md +109 -524
  4. package/THIRD_PARTY_NOTICES.txt +1 -28
  5. package/autostart/windows/account-task.ps1 +167 -0
  6. package/autostart/windows/inspect-task.ps1 +42 -0
  7. package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
  8. package/autostart/windows/legacy-access.ps1 +359 -0
  9. package/autostart/windows/legacy-task.ps1 +301 -0
  10. package/autostart/windows/register-task.ps1 +11 -2
  11. package/bin/account-task-adapter.mjs +47 -0
  12. package/bin/account-upgrade-controller.mjs +198 -0
  13. package/bin/account-upgrade-worker.mjs +141 -0
  14. package/bin/account-worker-session.mjs +215 -0
  15. package/bin/cli-dispatch.mjs +154 -0
  16. package/bin/cli-main.mjs +1063 -0
  17. package/bin/cli-selection.mjs +43 -0
  18. package/bin/cli.mjs +7 -876
  19. package/bin/dependency-lock.json +642 -0
  20. package/bin/legacy-1.3.json +32 -0
  21. package/bin/legacy-access.mjs +73 -0
  22. package/bin/legacy-installation.mjs +273 -0
  23. package/bin/legacy-task.mjs +76 -0
  24. package/bin/legacy-upgrade.mjs +527 -0
  25. package/bin/managed-runtime.mjs +170 -0
  26. package/bin/managed-state.mjs +257 -0
  27. package/bin/managed-upgrade.mjs +581 -0
  28. package/bin/mcp-bridge.mjs +150 -4
  29. package/bin/pooling-batches.mjs +13 -0
  30. package/bin/pooling-writer.mjs +4 -0
  31. package/bin/prewarm.mjs +59 -25
  32. package/bin/runtime-context.mjs +100 -0
  33. package/bin/service-control.mjs +32 -4
  34. package/bin/stable-launcher.mjs +54 -0
  35. package/bin/task-only-controller.mjs +226 -0
  36. package/bin/task-scope.mjs +65 -0
  37. package/bin/task-transaction-protocol.mjs +173 -0
  38. package/bin/top.mjs +22 -10
  39. package/bin/update-check.mjs +13 -2
  40. package/bin/upgrade-autostart.mjs +47 -0
  41. package/bin/upgrade-capability.json +9 -0
  42. package/bin/upgrade-dependencies.mjs +183 -0
  43. package/bin/upgrade-package.mjs +205 -0
  44. package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
  45. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  46. package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
  47. package/bin/windows-legacy/src/LegacyNative.cs +832 -0
  48. package/bin/windows-legacy/src/LegacyProcessBroker.cs +961 -0
  49. package/bin/windows-legacy-process.mjs +248 -0
  50. package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
  51. package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
  52. package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
  53. package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
  54. package/bin/windows-process-lifetime.mjs +218 -0
  55. package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
  56. package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
  57. package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
  58. package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
  59. package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
  60. package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
  61. package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
  62. package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
  63. package/bin/windows-task-channel.mjs +335 -0
  64. package/docs/account-upgrades.md +218 -0
  65. package/docs/compatibility.md +39 -0
  66. package/docs/configuration.md +146 -0
  67. package/docs/dashboards.md +125 -0
  68. package/docs/execution-context.md +119 -0
  69. package/docs/http-api.md +47 -0
  70. package/docs/install.md +52 -0
  71. package/docs/operations.md +99 -0
  72. package/docs/upgrades.md +324 -0
  73. package/package.json +19 -5
  74. package/supervisor/supervise.mjs +226 -40
  75. package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
  76. package/tools/windows-legacy/README.md +251 -0
  77. package/tools/windows-legacy/build.ps1 +122 -0
  78. package/tools/windows-legacy/inventory.mjs +92 -0
  79. package/tools/windows-process-lifetime/README.md +119 -0
  80. package/tools/windows-process-lifetime/build.ps1 +122 -0
  81. package/tools/windows-process-lifetime/inventory.mjs +86 -0
  82. package/tools/windows-task-channel/README.md +329 -0
  83. package/tools/windows-task-channel/build.ps1 +107 -0
  84. package/tools/windows-task-channel/inventory.mjs +117 -0
  85. package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
  86. package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
  87. package/ui/dist/assets/index-PVcuDNrN.css +1 -0
  88. package/ui/dist/index.html +2 -2
  89. package/ui/dist/third-party-manifest.json +37 -25
  90. package/ui/dist/assets/index-CaLPg0c-.css +0 -1
@@ -0,0 +1,122 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Builds or verifies the separate Windows legacy observed-set broker.
4
+ .DESCRIPTION
5
+ Uses installed Visual Studio Roslyn and .NET Framework 4.6.2 reference assemblies.
6
+ Compiles twice under different paths, records every input, and downloads nothing.
7
+ #>
8
+ [CmdletBinding()]
9
+ param(
10
+ [switch]$Verify,
11
+ [string]$CompilerPath,
12
+ [string]$ReferenceAssemblyPath
13
+ )
14
+
15
+ Set-StrictMode -Version Latest
16
+ $ErrorActionPreference = 'Stop'
17
+
18
+ function Get-Digest([string]$Path, [switch]$Text) {
19
+ $bytes = [IO.File]::ReadAllBytes($Path)
20
+ if ($Text) {
21
+ $bytes = [Text.Encoding]::UTF8.GetBytes([IO.File]::ReadAllText($Path).Replace("`r`n", "`n"))
22
+ }
23
+ $hash = [Security.Cryptography.SHA256]::Create()
24
+ try { return [BitConverter]::ToString($hash.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() }
25
+ finally { $hash.Dispose() }
26
+ }
27
+
28
+ if ($env:OS -ne 'Windows_NT') { throw 'Build on Windows with installed Roslyn and .NET Framework 4.6.2 references.' }
29
+ $root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
30
+ $directory = Join-Path $root 'bin\windows-legacy'
31
+ $binary = 'LegacyProcessBroker.exe'
32
+ $metadataPath = Join-Path $directory 'LegacyProcessBroker.build.json'
33
+ $sources = @('AssemblyInfo.cs', 'LegacyNative.cs', 'LegacyProcessBroker.cs')
34
+ $references = @('mscorlib.dll', 'System.dll', 'System.Core.dll', 'System.Management.dll', 'System.Web.Extensions.dll')
35
+ if (-not $CompilerPath) {
36
+ $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
37
+ if (-not (Test-Path -LiteralPath $vswhere)) { throw 'Specify an installed Roslyn -CompilerPath; no tools are installed.' }
38
+ $installation = & $vswhere -latest -products '*' -requires Microsoft.Component.MSBuild -property installationPath
39
+ if ($LASTEXITCODE -ne 0 -or
40
+ -not $installation) { throw 'No installed Visual Studio MSBuild compiler found.' }
41
+ $CompilerPath = Join-Path $installation 'MSBuild\Current\Bin\Roslyn\csc.exe'
42
+ }
43
+ if (-not $ReferenceAssemblyPath) {
44
+ $ReferenceAssemblyPath = Join-Path ${env:ProgramFiles(x86)} 'Reference Assemblies\Microsoft\Framework\.NETFramework\v4.6.2'
45
+ }
46
+ $CompilerPath = (Resolve-Path -LiteralPath $CompilerPath).Path
47
+ $ReferenceAssemblyPath = (Resolve-Path -LiteralPath $ReferenceAssemblyPath).Path
48
+ $framework = [xml][IO.File]::ReadAllText((Join-Path $ReferenceAssemblyPath 'RedistList\FrameworkList.xml'))
49
+ if ($framework.FileList.Name -cne '.NET Framework 4.6.2') { throw 'Wrong reference assembly targeting pack.' }
50
+ $version = (& $CompilerPath /version | Out-String).Trim()
51
+ if ($LASTEXITCODE -ne 0) { throw 'Roslyn version probe failed.' }
52
+ $options = @('/nologo', '/noconfig', '/nostdlib+', '/target:exe', '/platform:anycpu',
53
+ '/langversion:7.3', '/optimize+', '/debug-', '/deterministic+', '/checked-',
54
+ '/unsafe-', '/warn:4', '/warnaserror+', '/utf8output')
55
+ $sourceHashes = [ordered]@{}
56
+ foreach ($name in $sources) { $sourceHashes[$name] = Get-Digest (Join-Path $directory "src\$name") -Text }
57
+ $referenceHashes = [ordered]@{}
58
+ foreach ($name in $references) { $referenceHashes[$name] = Get-Digest (Join-Path $ReferenceAssemblyPath $name) }
59
+ $inputs = [ordered]@{
60
+ targetFramework = '.NETFramework,Version=v4.6.2'
61
+ platform = 'AnyCPU'
62
+ compilerVersion = $version
63
+ compilerSha256 = Get-Digest $CompilerPath
64
+ compilerArguments = $options
65
+ pathMap = '/_/mcp-pacemaker/windows-legacy'
66
+ sourceEncoding = 'UTF-8 without BOM, LF line endings'
67
+ sourceSha256 = $sourceHashes
68
+ buildScriptSha256 = Get-Digest $PSCommandPath -Text
69
+ referenceSha256 = $referenceHashes
70
+ }
71
+ $expected = $null
72
+ if ($Verify) {
73
+ $expected = [IO.File]::ReadAllText($metadataPath) | ConvertFrom-Json
74
+ if ($expected.schemaVersion -ne 1 -or
75
+ $expected.binary -cne $binary -or
76
+ (ConvertTo-Json $inputs -Depth 6 -Compress) -cne (ConvertTo-Json $expected.inputs -Depth 6 -Compress) -or
77
+ (Get-Digest (Join-Path $directory $binary)) -cne $expected.binarySha256) {
78
+ throw 'Packaged legacy broker inputs/toolchain/bytes do not match metadata. Verification changes nothing.'
79
+ }
80
+ }
81
+ $temporary = Join-Path ([IO.Path]::GetTempPath()) ('mcp-legacy-build-' + [Guid]::NewGuid().ToString('N'))
82
+ $files = [Collections.Generic.List[string]]::new()
83
+ $directories = [Collections.Generic.List[string]]::new()
84
+ $outputs = [Collections.Generic.List[string]]::new()
85
+ try {
86
+ $null = [IO.Directory]::CreateDirectory($temporary)
87
+ $directories.Add($temporary)
88
+ foreach ($pass in @('first', 'second')) {
89
+ $scratch = Join-Path $temporary $pass
90
+ $null = [IO.Directory]::CreateDirectory($scratch)
91
+ $directories.Add($scratch)
92
+ $paths = foreach ($name in $sources) {
93
+ $path = Join-Path $scratch $name
94
+ $files.Add($path)
95
+ $text = [IO.File]::ReadAllText((Join-Path $directory "src\$name")).Replace("`r`n", "`n")
96
+ [IO.File]::WriteAllText($path, $text, [Text.UTF8Encoding]::new($false))
97
+ $path
98
+ }
99
+ $output = Join-Path $scratch $binary
100
+ $files.Add($output)
101
+ $arguments = $options + @("/out:$output", "/pathmap:$scratch=$($inputs.pathMap)")
102
+ foreach ($name in $references) { $arguments += '/reference:' + (Join-Path $ReferenceAssemblyPath $name) }
103
+ & $CompilerPath @arguments @paths
104
+ if ($LASTEXITCODE -ne 0) { throw "Legacy broker compile failed: $pass" }
105
+ $outputs.Add($output)
106
+ }
107
+ $digest = Get-Digest $outputs[0]
108
+ if ($digest -cne (Get-Digest $outputs[1])) { throw 'Two builds differ; no package assets changed.' }
109
+ if ($Verify) {
110
+ if ($digest -cne $expected.binarySha256) { throw 'Rebuilt bytes differ; no package assets changed.' }
111
+ Write-Output "Verified legacy broker source, toolchain and byte-for-byte rebuild: SHA256 $digest"
112
+ } else {
113
+ $metadata = [ordered]@{ schemaVersion = 1; binary = $binary; binarySha256 = $digest; inputs = $inputs }
114
+ [IO.File]::Copy($outputs[0], (Join-Path $directory $binary), $true)
115
+ [IO.File]::WriteAllText($metadataPath,
116
+ (ConvertTo-Json $metadata -Depth 8).Replace("`r`n", "`n") + "`n", [Text.UTF8Encoding]::new($false))
117
+ Write-Output "Built two matching legacy brokers with Roslyn ${version}: SHA256 $digest"
118
+ }
119
+ } finally {
120
+ foreach ($path in $files) { Remove-Item -LiteralPath $path -ErrorAction SilentlyContinue }
121
+ for ($index = $directories.Count - 1; $index -ge 0; $index--) { [IO.Directory]::Delete($directories[$index]) }
122
+ }
@@ -0,0 +1,92 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import { lstatSync, readFileSync, readdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ export const LEGACY_BROKER_FILES = Object.freeze([
7
+ 'bin/windows-legacy-process.mjs',
8
+ 'bin/windows-legacy/LegacyProcessBroker.exe',
9
+ 'bin/windows-legacy/LegacyProcessBroker.build.json',
10
+ 'bin/windows-legacy/src/AssemblyInfo.cs',
11
+ 'bin/windows-legacy/src/LegacyNative.cs',
12
+ 'bin/windows-legacy/src/LegacyProcessBroker.cs',
13
+ 'tools/windows-legacy/build.ps1',
14
+ 'tools/windows-legacy/inventory.mjs',
15
+ 'tools/windows-legacy/README.md',
16
+ ]);
17
+ export const LEGACY_BROKER_TEST = 'test/windows-legacy-process.test.mjs';
18
+ export const LEGACY_BROKER_REQUIRED_TESTS = Object.freeze([
19
+ 'legacy API denials preserve refusal and bounded stored-only failure diagnostics',
20
+ 'legacy failure metadata cannot mask errors or cross concurrent operation identities',
21
+ 'legacy stop sweep stops descendants of held parents and skips reused-PID edges',
22
+ 'legacy protected-plan model enforces 512 combined identities and UTF8 byte bounds without process queries',
23
+ 'legacy expected request refuses oversized UTF8 framing before native discovery',
24
+ ...['relative', 'drive-relative', 'root-relative'].flatMap((spelling) =>
25
+ ['bridge', 'supervisor'].map((affected) =>
26
+ `legacy script identity rejects ${affected} ${spelling} argv without stopping targets`)),
27
+ 'legacy script identity accepts fully qualified Unicode and space paths',
28
+ 'legacy script qualification recognizes drive and UNC forms without cwd resolution',
29
+ 'legacy script identity preserves fully qualified short-path handling',
30
+ 'legacy script identity does not equate a junction spelling with a different canonical root',
31
+ 'legacy broker plans only its roots and stops the processes they started',
32
+ 'legacy broker stops a server the bridge ran through cmd.exe, which Node ends with the bridge',
33
+ 'legacy broker refuses an upgrade started from inside the 1.3 tree without stopping anything',
34
+ 'legacy broker close is read-only and an identical expected plan can be reopened',
35
+ 'legacy broker reopens its plan while sessions start and end, and stops the later ones',
36
+ 'legacy broker rejects a stale root generation without stopping roots',
37
+ 'legacy broker rejects wrong root and malformed protected verification plan',
38
+ 'legacy broker refuses extra supervisor argv and a foreign listener without process mutation',
39
+ 'legacy read-only recovery does not stop an unrelated process reusing the endpoint',
40
+ 'legacy native inventory rejects changed executable, source and build metadata',
41
+ 'legacy prepare and recovery verify refuse wrong-hash packaged helper before any discovery',
42
+ 'legacy broker parent loss releases read-only capture without stopping legacy roots',
43
+ 'legacy broker reports a session process it could not stop and still verifies the root stop',
44
+ 'legacy broker preserves Unicode plan identity across fragmented UTF8 pipe messages',
45
+ 'legacy broker sweep leaves an older process alive when it names a stopped root as parent',
46
+ ]);
47
+
48
+ export function verifyLegacyBrokerAssets(root) {
49
+ for (const path of ['bin', 'bin/windows-legacy', 'bin/windows-legacy/src',
50
+ 'tools', 'tools/windows-legacy']) {
51
+ const stat = lstatSync(join(root, path));
52
+ assert.ok(stat.isDirectory() &&
53
+ !stat.isSymbolicLink(), `Linked or invalid legacy broker directory: ${path}`);
54
+ }
55
+ const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
56
+ const files = LEGACY_BROKER_FILES.map((path) => {
57
+ const full = join(root, path);
58
+ const stat = lstatSync(full);
59
+ assert.ok(stat.isFile() &&
60
+ !stat.isSymbolicLink() &&
61
+ stat.size > 0, `Invalid legacy broker asset: ${path}`);
62
+ return { path, sha256: hash(readFileSync(full)) };
63
+ });
64
+ assert.deepEqual(readdirSync(join(root, 'bin/windows-legacy')).sort(), [
65
+ 'LegacyProcessBroker.build.json', 'LegacyProcessBroker.exe', 'src',
66
+ ]);
67
+ const sources = ['AssemblyInfo.cs', 'LegacyNative.cs', 'LegacyProcessBroker.cs'];
68
+ assert.deepEqual(readdirSync(join(root, 'bin/windows-legacy/src')).sort(), sources);
69
+ const metadata = JSON.parse(readFileSync(join(root, 'bin/windows-legacy/LegacyProcessBroker.build.json')));
70
+ assert.equal(metadata.schemaVersion, 1);
71
+ assert.equal(metadata.binary, 'LegacyProcessBroker.exe');
72
+ assert.equal(metadata.inputs.targetFramework, '.NETFramework,Version=v4.6.2');
73
+ assert.equal(metadata.inputs.platform, 'AnyCPU');
74
+ assert.deepEqual(Object.keys(metadata.inputs.sourceSha256).sort(), sources);
75
+ assert.deepEqual(Object.keys(metadata.inputs.referenceSha256).sort(),
76
+ ['System.Core.dll', 'System.Management.dll', 'System.Web.Extensions.dll', 'System.dll', 'mscorlib.dll']);
77
+ assert.match(metadata.inputs.compilerSha256, /^[a-f0-9]{64}$/);
78
+ assert.deepEqual(metadata.inputs.compilerArguments, [
79
+ '/nologo', '/noconfig', '/nostdlib+', '/target:exe', '/platform:anycpu', '/langversion:7.3',
80
+ '/optimize+', '/debug-', '/deterministic+', '/checked-', '/unsafe-', '/warn:4', '/warnaserror+', '/utf8output',
81
+ ]);
82
+ assert.equal(files.find((file) => file.path.endsWith('.exe')).sha256, metadata.binarySha256);
83
+ const inputs = sources.map((name) => [`bin/windows-legacy/src/${name}`, metadata.inputs.sourceSha256[name]]);
84
+ inputs.push(['tools/windows-legacy/build.ps1', metadata.inputs.buildScriptSha256]);
85
+ for (const [path, expected] of inputs) {
86
+ assert.equal(hash(readFileSync(join(root, path), 'utf8').replaceAll('\r\n', '\n')), expected,
87
+ `Changed legacy broker input: ${path}`);
88
+ }
89
+ return { status: 'source-and-binary-hashes-verified', files,
90
+ binarySha256: metadata.binarySha256, compilerSha256: metadata.inputs.compilerSha256,
91
+ reproducibilityBuild: 'requires-recorded-toolchain-verify' };
92
+ }
@@ -0,0 +1,119 @@
1
+ # Windows process lifetime helper
2
+
3
+ The bridge awaits this packaged, own-source .NET Framework 4.6.2 program before
4
+ reading config, listening, or spawning workloads. It has no npm dependencies,
5
+ runtime compilation, PowerShell invocation, downloads, elevation, or service setup.
6
+
7
+ Before **each owner and observer launch**, the Node module hashes the actual
8
+ executable bytes and compares them with the packaged metadata's recorded SHA256.
9
+ Missing files, invalid metadata and mismatched bytes fail before native spawn.
10
+ This is a packaged-identity guard, not protection against a same-user actor
11
+ replacing both the executable and metadata, or replacing the whole package.
12
+
13
+ ## Ownership
14
+
15
+ The helper opens its **actual parent** from native process information and retains
16
+ that process handle. It requires `watch-parent <caller PID>` and checks that PID
17
+ against its actual parent; it cannot select an unrelated target. Bare invocation
18
+ fails before assigning any process. This is an internal bridge launch contract,
19
+ not a standalone command to run in a shell.
20
+
21
+ Creation-time ordering rejects a reused parent PID, and an already exited parent
22
+ is refused. It creates an unnamed, non-inheritable Job Object
23
+ with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, without either breakaway flag, assigns
24
+ the bridge, verifies membership, and only then writes `MCP_JOB_READY`.
25
+
26
+ The bridge is not suspended: it is awaiting that acknowledgement and cannot start
27
+ any work yet. Every later Windows child inherits the job **during process
28
+ creation**, before its first instruction. This covers isolated, pool, shared,
29
+ auth-command, and config-write helper spawns, including cmd/npx wrappers. There is
30
+ no start-then-assign race, post-crash PID sweep, process enumeration in production,
31
+ or fallback to uncontained spawning.
32
+
33
+ The owner was started before the bridge joined this job and remains outside it.
34
+ It alone holds the non-inheritable job handle. On bridge exit, the held process
35
+ handle signals and the owner closes the job, terminating surviving descendants.
36
+ Both native roles use detached Windows launches, so Node's automatic direct-child
37
+ termination does not kill the owner before it can drain. These are not unowned
38
+ daemons: each role watches its actual parent's held process handle and exits when
39
+ that parent is lost. Owner stdio/process references do not keep an otherwise
40
+ finished bridge alive.
41
+ Killing the owner also closes the job and terminates the bridge and descendants.
42
+ Normal DELETE/recycle still uses the existing per-session tree cleanup; shared
43
+ detach, counters and request replay rules are unchanged. POSIX does not launch
44
+ this helper or change its process behavior.
45
+
46
+ A bridge exit notification can precede final job teardown. This mechanism does
47
+ not adopt or clean processes abandoned by an earlier runtime.
48
+
49
+ ## Verified managed stop
50
+
51
+ `ensureWindowsProcessLifetime()` returns
52
+ `{protocol:1, ownerPid, ownerCreationTime}` on Windows (`null` on POSIX).
53
+ The creation identity is a decimal **Windows FILETIME string**, not a rounded
54
+ JavaScript timestamp or .NET DateTime tick count. The identity comes from the
55
+ native owner after assignment and its PID must match the spawned child.
56
+
57
+ The supervisor calls `await observeWindowsProcessLifetime(identity)` before
58
+ declaring the bridge ready or permitting stop. It returns `{done, pid}` after a
59
+ separate native observer has opened the owner, checked its exact creation time
60
+ and same helper image, and retained that process handle. It also opens a
61
+ read-only synchronization handle to the owner's initially-unsignaled drain
62
+ event. The owner creates that event before readiness, names it from its PID and
63
+ creation time, and refuses a pre-existing event. Neither handle is inherited.
64
+ The observer is outside
65
+ the bridge job, read-only, and also watches its own parent: losing the supervisor
66
+ does not orphan the observer. Unavailable/reused identities fail closed.
67
+
68
+ After root exit the owner calls `TerminateJobObject`, then queries actual job
69
+ accounting until `ActiveProcesses == 0`, bounded to five seconds. Only successful
70
+ drain signals that held event and returns exit code zero. Kill-on-close remains
71
+ the failure backstop. The observer waits on the held owner handle, checks both
72
+ successful exit and the signaled event, emits the
73
+ drain receipt, then exits itself. Only after observer exit and complete output
74
+ does `done` resolve to `{verified:true, ownerExited:true, activeProcesses:0}`.
75
+ Forced owner death (including a forced exit code zero) or a failed drain rejects
76
+ `done`; PID disappearance or an exit code alone is never accepted as proof.
77
+ These session-local kernel events leave no files or persistent settings.
78
+
79
+ Managed startup sends the owner identity early and waits for the supervisor's
80
+ observer acknowledgement before config initialization. This avoids losing the
81
+ owner before failed-start cleanup can arm observation. Budget root exit and
82
+ `observer.done` inside one existing stop deadline, not two sequential deadlines.
83
+
84
+ Windows 10/11 and Server 2016+ with .NET Framework 4.6.2+ are required. An outer job
85
+ whose restrictions prevent nested assignment causes explicit startup failure,
86
+ not an uncontained bridge. ARM64 execution remains an outstanding platform gate.
87
+ The historical configuration security helper is separate and unchanged.
88
+
89
+ ## Build and verify
90
+
91
+ Use the already installed Visual Studio Roslyn compiler and 4.6.2 targeting pack:
92
+
93
+ ```powershell
94
+ .\tools\windows-process-lifetime\build.ps1
95
+ .\tools\windows-process-lifetime\build.ps1 -Verify
96
+ # Optional: select the exact recorded, installed compiler/reference directory.
97
+ .\tools\windows-process-lifetime\build.ps1 -Verify -CompilerPath C:\path\to\Roslyn\csc.exe
98
+ ```
99
+
100
+ Both modes compile twice in different owned temporary directories and require
101
+ identical bytes. Verification also requires the recorded compiler, references,
102
+ normalized source/build-script hashes and packaged binary hash; it updates
103
+ nothing. Builds install no tools and clean their explicitly recorded scratch
104
+ files. Only the entry point and P/Invoke declarations are static.
105
+
106
+ `inventory.mjs` checks the exact separate asset layout and normalized build inputs
107
+ on every platform. It does not substitute for the byte-for-byte Windows rebuild.
108
+ Package/native gates require these assets for current versions starting at 2.0.2;
109
+ the immutable 1.3.1 security-helper baseline is not relabeled or modified.
110
+
111
+ ## Regression
112
+
113
+ `test/windows-process-lifetime.test.mjs` uses only owned synthetic nested
114
+ `cmd -> Node stand-in -> cmd -> Node worker` processes, dynamic loopback TCP
115
+ heartbeats, MCP readiness, 45-second expiry backstops and captured
116
+ PID/creation-time/held-handle cleanup. DELETE/recycle are controls; abrupt bridge
117
+ loss must stop both heartbeats and terminate every captured descendant. Evidence
118
+ is retained outside Git; set `MCP_LIFETIME_TEST_EVIDENCE` to an existing private
119
+ evidence parent. The known historical live bridge exit trigger remains unknown.
@@ -0,0 +1,122 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Builds or verifies the separate Windows process lifetime helper.
4
+ .DESCRIPTION
5
+ Uses installed Visual Studio Roslyn and .NET Framework 4.6.2 reference assemblies.
6
+ Compiles twice under different paths, records every input, and downloads nothing.
7
+ #>
8
+ [CmdletBinding()]
9
+ param(
10
+ [switch]$Verify,
11
+ [string]$CompilerPath,
12
+ [string]$ReferenceAssemblyPath
13
+ )
14
+
15
+ Set-StrictMode -Version Latest
16
+ $ErrorActionPreference = 'Stop'
17
+
18
+ function Get-Digest([string]$Path, [switch]$Text) {
19
+ $bytes = [IO.File]::ReadAllBytes($Path)
20
+ if ($Text) {
21
+ $bytes = [Text.Encoding]::UTF8.GetBytes([IO.File]::ReadAllText($Path).Replace("`r`n", "`n"))
22
+ }
23
+ $hash = [Security.Cryptography.SHA256]::Create()
24
+ try { return [BitConverter]::ToString($hash.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() }
25
+ finally { $hash.Dispose() }
26
+ }
27
+
28
+ if ($env:OS -ne 'Windows_NT') { throw 'Build on Windows with installed Roslyn and .NET Framework 4.6.2 references.' }
29
+ $root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
30
+ $directory = Join-Path $root 'bin\windows-lifetime'
31
+ $binary = 'ProcessLifetimeHelper.exe'
32
+ $metadataPath = Join-Path $directory 'ProcessLifetimeHelper.build.json'
33
+ $sources = @('AssemblyInfo.cs', 'ProcessLifetimeHelper.cs')
34
+ $references = @('mscorlib.dll', 'System.dll')
35
+ if (-not $CompilerPath) {
36
+ $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
37
+ if (-not (Test-Path -LiteralPath $vswhere)) { throw 'Specify an installed Roslyn -CompilerPath; no tools are installed.' }
38
+ $installation = & $vswhere -latest -products '*' -requires Microsoft.Component.MSBuild -property installationPath
39
+ if ($LASTEXITCODE -ne 0 -or
40
+ -not $installation) { throw 'No installed Visual Studio MSBuild compiler found.' }
41
+ $CompilerPath = Join-Path $installation 'MSBuild\Current\Bin\Roslyn\csc.exe'
42
+ }
43
+ if (-not $ReferenceAssemblyPath) {
44
+ $ReferenceAssemblyPath = Join-Path ${env:ProgramFiles(x86)} 'Reference Assemblies\Microsoft\Framework\.NETFramework\v4.6.2'
45
+ }
46
+ $CompilerPath = (Resolve-Path -LiteralPath $CompilerPath).Path
47
+ $ReferenceAssemblyPath = (Resolve-Path -LiteralPath $ReferenceAssemblyPath).Path
48
+ $framework = [xml][IO.File]::ReadAllText((Join-Path $ReferenceAssemblyPath 'RedistList\FrameworkList.xml'))
49
+ if ($framework.FileList.Name -cne '.NET Framework 4.6.2') { throw 'Wrong reference assembly targeting pack.' }
50
+ $version = (& $CompilerPath /version | Out-String).Trim()
51
+ if ($LASTEXITCODE -ne 0) { throw 'Roslyn version probe failed.' }
52
+ $options = @('/nologo', '/noconfig', '/nostdlib+', '/target:exe', '/platform:anycpu',
53
+ '/langversion:7.3', '/optimize+', '/debug-', '/deterministic+', '/checked-',
54
+ '/unsafe-', '/warn:4', '/warnaserror+', '/utf8output')
55
+ $sourceHashes = [ordered]@{}
56
+ foreach ($name in $sources) { $sourceHashes[$name] = Get-Digest (Join-Path $directory "src\$name") -Text }
57
+ $referenceHashes = [ordered]@{}
58
+ foreach ($name in $references) { $referenceHashes[$name] = Get-Digest (Join-Path $ReferenceAssemblyPath $name) }
59
+ $inputs = [ordered]@{
60
+ targetFramework = '.NETFramework,Version=v4.6.2'
61
+ platform = 'AnyCPU'
62
+ compilerVersion = $version
63
+ compilerSha256 = Get-Digest $CompilerPath
64
+ compilerArguments = $options
65
+ pathMap = '/_/mcp-pacemaker/windows-process-lifetime'
66
+ sourceEncoding = 'UTF-8 without BOM, LF line endings'
67
+ sourceSha256 = $sourceHashes
68
+ buildScriptSha256 = Get-Digest $PSCommandPath -Text
69
+ referenceSha256 = $referenceHashes
70
+ }
71
+ $expected = $null
72
+ if ($Verify) {
73
+ $expected = [IO.File]::ReadAllText($metadataPath) | ConvertFrom-Json
74
+ if ($expected.schemaVersion -ne 1 -or
75
+ $expected.binary -cne $binary -or
76
+ (ConvertTo-Json $inputs -Depth 6 -Compress) -cne (ConvertTo-Json $expected.inputs -Depth 6 -Compress) -or
77
+ (Get-Digest (Join-Path $directory $binary)) -cne $expected.binarySha256) {
78
+ throw 'Packaged lifetime helper inputs/toolchain/bytes do not match metadata. Verification changes nothing.'
79
+ }
80
+ }
81
+ $temporary = Join-Path ([IO.Path]::GetTempPath()) ('mcp-lifetime-build-' + [Guid]::NewGuid().ToString('N'))
82
+ $files = [Collections.Generic.List[string]]::new()
83
+ $directories = [Collections.Generic.List[string]]::new()
84
+ $outputs = [Collections.Generic.List[string]]::new()
85
+ try {
86
+ $null = [IO.Directory]::CreateDirectory($temporary)
87
+ $directories.Add($temporary)
88
+ foreach ($pass in @('first', 'second')) {
89
+ $scratch = Join-Path $temporary $pass
90
+ $null = [IO.Directory]::CreateDirectory($scratch)
91
+ $directories.Add($scratch)
92
+ $paths = foreach ($name in $sources) {
93
+ $path = Join-Path $scratch $name
94
+ $files.Add($path)
95
+ $text = [IO.File]::ReadAllText((Join-Path $directory "src\$name")).Replace("`r`n", "`n")
96
+ [IO.File]::WriteAllText($path, $text, [Text.UTF8Encoding]::new($false))
97
+ $path
98
+ }
99
+ $output = Join-Path $scratch $binary
100
+ $files.Add($output)
101
+ $arguments = $options + @("/out:$output", "/pathmap:$scratch=$($inputs.pathMap)")
102
+ foreach ($name in $references) { $arguments += '/reference:' + (Join-Path $ReferenceAssemblyPath $name) }
103
+ & $CompilerPath @arguments @paths
104
+ if ($LASTEXITCODE -ne 0) { throw "Lifetime helper compile failed: $pass" }
105
+ $outputs.Add($output)
106
+ }
107
+ $digest = Get-Digest $outputs[0]
108
+ if ($digest -cne (Get-Digest $outputs[1])) { throw 'Two builds differ; no package assets changed.' }
109
+ if ($Verify) {
110
+ if ($digest -cne $expected.binarySha256) { throw 'Rebuilt bytes differ; no package assets changed.' }
111
+ Write-Output "Verified lifetime helper source, toolchain and byte-for-byte rebuild: SHA256 $digest"
112
+ } else {
113
+ $metadata = [ordered]@{ schemaVersion = 1; binary = $binary; binarySha256 = $digest; inputs = $inputs }
114
+ [IO.File]::Copy($outputs[0], (Join-Path $directory $binary), $true)
115
+ [IO.File]::WriteAllText($metadataPath,
116
+ (ConvertTo-Json $metadata -Depth 8).Replace("`r`n", "`n") + "`n", [Text.UTF8Encoding]::new($false))
117
+ Write-Output "Built two matching lifetime helpers with Roslyn ${version}: SHA256 $digest"
118
+ }
119
+ } finally {
120
+ foreach ($path in $files) { Remove-Item -LiteralPath $path -ErrorAction SilentlyContinue }
121
+ for ($index = $directories.Count - 1; $index -ge 0; $index--) { [IO.Directory]::Delete($directories[$index]) }
122
+ }
@@ -0,0 +1,86 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHash } from 'node:crypto';
3
+ import { lstatSync, readFileSync, readdirSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ export const LIFETIME_FILES = Object.freeze([
7
+ 'bin/windows-process-lifetime.mjs',
8
+ 'bin/windows-lifetime/ProcessLifetimeHelper.exe',
9
+ 'bin/windows-lifetime/ProcessLifetimeHelper.build.json',
10
+ 'bin/windows-lifetime/src/AssemblyInfo.cs',
11
+ 'bin/windows-lifetime/src/ProcessLifetimeHelper.cs',
12
+ 'tools/windows-process-lifetime/build.ps1',
13
+ 'tools/windows-process-lifetime/inventory.mjs',
14
+ 'tools/windows-process-lifetime/README.md',
15
+ ]);
16
+ export const LIFETIME_TEST = 'test/windows-process-lifetime.test.mjs';
17
+ export const LIFETIME_STARTUP_TEST = 'test/windows-lifetime-startup.test.mjs';
18
+ export const LIFETIME_IDENTITY_TEST = 'test/windows-lifetime-identity.test.mjs';
19
+ export const LIFETIME_REQUIRED_TESTS = Object.freeze([
20
+ ...['normal-delete', 'recycle', 'abrupt-root-loss', 'shared-abrupt', 'concurrent-abrupt',
21
+ 'pool-abrupt', 'auth-abrupt', 'owner-loss', 'supervisor-restart']
22
+ .map((mode) => `Windows lifetime: ${mode} leaves no nested descendants`),
23
+ ...['gated-success', 'missing', 'invalid', 'assignment-failure', 'observer-owner-loss',
24
+ 'observer-stale', 'observer-wrong-image', 'observer-parent-loss', 'observer-zero-exit']
25
+ .map((mode) => `Windows lifetime startup: ${mode}`),
26
+ 'lifetime helper bare or unrelated-parent invocation cannot assign the caller',
27
+ ...['missing', 'altered', 'stale', 'protocol'].flatMap((kind) =>
28
+ ['owner', 'observer'].map((action) => `packaged helper identity rejects ${kind} before ${action} spawn`)),
29
+ ]);
30
+
31
+ export function requiresProcessLifetime(version) {
32
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
33
+ assert.ok(match, 'Lifetime gate requires a canonical stable version');
34
+ return Number(match[1]) > 2 ||
35
+ Number(match[1]) === 2 &&
36
+ (Number(match[2]) > 0 || Number(match[3]) >= 2);
37
+ }
38
+
39
+ export function verifyProcessLifetimeAssets(root, version) {
40
+ if (!requiresProcessLifetime(version)) return { status: 'not-required-for-historical-version' };
41
+ for (const path of ['bin', 'bin/windows-lifetime', 'bin/windows-lifetime/src',
42
+ 'tools', 'tools/windows-process-lifetime']) {
43
+ const stat = lstatSync(join(root, path));
44
+ assert.ok(stat.isDirectory() &&
45
+ !stat.isSymbolicLink(), `Linked or invalid lifetime asset directory: ${path}`);
46
+ }
47
+ const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
48
+ const files = LIFETIME_FILES.map((path) => {
49
+ const full = join(root, path);
50
+ const stat = lstatSync(full);
51
+ assert.ok(stat.isFile() &&
52
+ !stat.isSymbolicLink() &&
53
+ stat.size > 0, `Invalid lifetime package asset: ${path}`);
54
+ return { path, sha256: hash(readFileSync(full)) };
55
+ });
56
+ assert.deepEqual(readdirSync(join(root, 'bin/windows-lifetime')).sort(), [
57
+ 'ProcessLifetimeHelper.build.json', 'ProcessLifetimeHelper.exe', 'src',
58
+ ]);
59
+ assert.deepEqual(readdirSync(join(root, 'bin/windows-lifetime/src')).sort(), [
60
+ 'AssemblyInfo.cs', 'ProcessLifetimeHelper.cs',
61
+ ]);
62
+ const metadata = JSON.parse(readFileSync(join(root, 'bin/windows-lifetime/ProcessLifetimeHelper.build.json')));
63
+ assert.equal(metadata.schemaVersion, 1);
64
+ assert.equal(metadata.binary, 'ProcessLifetimeHelper.exe');
65
+ assert.equal(metadata.inputs.targetFramework, '.NETFramework,Version=v4.6.2');
66
+ assert.equal(metadata.inputs.platform, 'AnyCPU');
67
+ assert.deepEqual(Object.keys(metadata.inputs.sourceSha256).sort(), ['AssemblyInfo.cs', 'ProcessLifetimeHelper.cs']);
68
+ assert.deepEqual(Object.keys(metadata.inputs.referenceSha256).sort(), ['System.dll', 'mscorlib.dll']);
69
+ assert.match(metadata.inputs.compilerSha256, /^[a-f0-9]{64}$/);
70
+ assert.deepEqual(metadata.inputs.compilerArguments, [
71
+ '/nologo', '/noconfig', '/nostdlib+', '/target:exe', '/platform:anycpu',
72
+ '/langversion:7.3', '/optimize+', '/debug-', '/deterministic+', '/checked-',
73
+ '/unsafe-', '/warn:4', '/warnaserror+', '/utf8output',
74
+ ]);
75
+ assert.equal(files.find((file) => file.path.endsWith('.exe')).sha256, metadata.binarySha256);
76
+ const sources = Object.entries(metadata.inputs.sourceSha256)
77
+ .map(([name, expected]) => [`bin/windows-lifetime/src/${name}`, expected]);
78
+ sources.push(['tools/windows-process-lifetime/build.ps1', metadata.inputs.buildScriptSha256]);
79
+ for (const [path, expected] of sources) {
80
+ assert.equal(hash(readFileSync(join(root, path), 'utf8').replaceAll('\r\n', '\n')), expected,
81
+ `Lifetime helper build input changed: ${path}`);
82
+ }
83
+ return { status: 'source-and-binary-hashes-verified', files,
84
+ compilerSha256: metadata.inputs.compilerSha256, binarySha256: metadata.binarySha256,
85
+ reproducibilityBuild: 'requires-recorded-toolchain-verify' };
86
+ }