mcp-pacemaker 2.0.2 → 2.1.0
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.
- package/CHANGELOG.md +75 -1
- package/CONTRIBUTING.md +39 -13
- package/README.md +255 -8
- package/THIRD_PARTY_NOTICES.txt +1 -28
- package/autostart/windows/account-task.ps1 +167 -0
- package/autostart/windows/inspect-task.ps1 +42 -0
- package/autostart/windows/inspect-worker-process-sessions.ps1 +155 -0
- package/autostart/windows/legacy-task.ps1 +301 -0
- package/autostart/windows/register-task.ps1 +11 -2
- package/bin/account-task-adapter.mjs +47 -0
- package/bin/account-upgrade-controller.mjs +198 -0
- package/bin/account-upgrade-worker.mjs +134 -0
- package/bin/account-worker-session.mjs +215 -0
- package/bin/cli-dispatch.mjs +154 -0
- package/bin/cli-main.mjs +1007 -0
- package/bin/cli-selection.mjs +43 -0
- package/bin/cli.mjs +7 -876
- package/bin/legacy-1.3.json +32 -0
- package/bin/legacy-installation.mjs +217 -0
- package/bin/legacy-task.mjs +52 -0
- package/bin/legacy-upgrade.mjs +471 -0
- package/bin/managed-runtime.mjs +170 -0
- package/bin/managed-state.mjs +257 -0
- package/bin/managed-upgrade.mjs +491 -0
- package/bin/mcp-bridge.mjs +132 -4
- package/bin/pooling-batches.mjs +13 -0
- package/bin/pooling-writer.mjs +4 -0
- package/bin/prewarm.mjs +33 -21
- package/bin/runtime-context.mjs +100 -0
- package/bin/service-control.mjs +27 -4
- package/bin/stable-launcher.mjs +54 -0
- package/bin/task-only-controller.mjs +226 -0
- package/bin/task-scope.mjs +65 -0
- package/bin/task-transaction-protocol.mjs +173 -0
- package/bin/top.mjs +22 -10
- package/bin/update-check.mjs +13 -2
- package/bin/upgrade-autostart.mjs +47 -0
- package/bin/upgrade-capability.json +8 -0
- package/bin/upgrade-dependencies.mjs +122 -0
- package/bin/upgrade-package.mjs +203 -0
- package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
- package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
- package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
- package/bin/windows-legacy/src/LegacyNative.cs +783 -0
- package/bin/windows-legacy/src/LegacyProcessBroker.cs +841 -0
- package/bin/windows-legacy-process.mjs +241 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
- package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
- package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
- package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
- package/bin/windows-process-lifetime.mjs +218 -0
- package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
- package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
- package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
- package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
- package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
- package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
- package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
- package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
- package/bin/windows-task-channel.mjs +335 -0
- package/docs/account-upgrades.md +205 -0
- package/docs/execution-context.md +113 -0
- package/package.json +12 -5
- package/supervisor/supervise.mjs +226 -40
- package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
- package/tools/windows-legacy/README.md +225 -0
- package/tools/windows-legacy/build.ps1 +122 -0
- package/tools/windows-legacy/inventory.mjs +90 -0
- package/tools/windows-process-lifetime/README.md +119 -0
- package/tools/windows-process-lifetime/build.ps1 +122 -0
- package/tools/windows-process-lifetime/inventory.mjs +86 -0
- package/tools/windows-task-channel/README.md +329 -0
- package/tools/windows-task-channel/build.ps1 +107 -0
- package/tools/windows-task-channel/inventory.mjs +117 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
- package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
- package/ui/dist/assets/index-PVcuDNrN.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/third-party-manifest.json +37 -25
- package/ui/dist/assets/index-CaLPg0c-.css +0 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Legacy observed-process broker
|
|
2
|
+
|
|
3
|
+
This separate own-source .NET Framework 4.6.2 executable supports an explicitly
|
|
4
|
+
partial, restart-only legacy handoff. It does not change the modern lifetime
|
|
5
|
+
helper or its Job Object proof. No npm runtime dependencies, compiler, PowerShell,
|
|
6
|
+
downloads, privilege changes or service/task mutations run inside this broker.
|
|
7
|
+
|
|
8
|
+
## API and caller responsibilities
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
const session = await prepareLegacyProcesses({ port, root, expected });
|
|
12
|
+
const plan = session.plan;
|
|
13
|
+
const current = await session.status(); // read-only held-handle status
|
|
14
|
+
const receipt = await session.stop(); // consumes this session's stop once
|
|
15
|
+
await session.close(); // closes handles; does not stop targets
|
|
16
|
+
const recovered = await verifyLegacyProcessesGone(plan); // read-only
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`root` is an absolute existing package directory. `port` is an integer 1..65535.
|
|
20
|
+
Discovery requires exactly one IPv4 127.0.0.1 listener on that port; wildcard or
|
|
21
|
+
ambiguous listeners refuse. It opens the bridge and its native parent, verifies
|
|
22
|
+
both user SIDs match the broker's user, and requires these parsed argument lists:
|
|
23
|
+
|
|
24
|
+
- `node.exe <root>/bin/mcp-bridge.mjs --port <N>`
|
|
25
|
+
- `pwsh.exe|powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden
|
|
26
|
+
-File <root>/supervisor/supervise.ps1 -Port <N>`
|
|
27
|
+
|
|
28
|
+
Actual image paths and SHA256 values are recorded, not assumed to equal the
|
|
29
|
+
upgrader's Node executable. Parsed argv is hashed and not emitted. Native
|
|
30
|
+
creation time is an exact decimal Windows FILETIME string. WMI command-line
|
|
31
|
+
reads are checked against the held process generation. No cwd/PEB read, process
|
|
32
|
+
injection or debugging is used.
|
|
33
|
+
|
|
34
|
+
Both script arguments must be fully qualified Windows drive-absolute or ordinary
|
|
35
|
+
UNC paths before comparison. Relative, drive-relative (`C:script.ps1`),
|
|
36
|
+
root-relative (`\script.ps1`) and device-namespace spellings refuse; the broker
|
|
37
|
+
never resolves target arguments against its own cwd or guesses the target cwd.
|
|
38
|
+
Existing case-insensitive full-path comparison and native executable identity
|
|
39
|
+
queries are unchanged.
|
|
40
|
+
|
|
41
|
+
Child discovery uses one documented `TH32CS_SNAPPROCESS` snapshot per capture
|
|
42
|
+
or revalidation phase. At most 16,384 PID/parent pairs are retained in memory.
|
|
43
|
+
The native entry buffer also contains other OS fields; the broker reads only
|
|
44
|
+
the PID/parent offsets, never marshals names, and clears that buffer after each
|
|
45
|
+
entry. It closes the snapshot before opening candidates. No global pair list
|
|
46
|
+
is returned or logged, and no unrelated entries receive process/token/image
|
|
47
|
+
queries. Snapshot overflow, layout/duplicate/enumeration failure and
|
|
48
|
+
`ERROR_BAD_LENGTH` refuse the whole operation without retry.
|
|
49
|
+
|
|
50
|
+
Snapshot pairs are discovery hints, not process authority or an atomic tree.
|
|
51
|
+
Every candidate must still bind to an already-held live parent through native
|
|
52
|
+
parent/creation/owner checks. Unknown historical branches remain unproven.
|
|
53
|
+
Fresh revalidation refuses added or changed generations; a reused numeric PID
|
|
54
|
+
cannot replace an exited captured handle.
|
|
55
|
+
|
|
56
|
+
Argv queries cover only held live IDs, at most 32 per query and four concurrent
|
|
57
|
+
workers. Each worker owns its connected WMI scope/searcher/cursor. Missing,
|
|
58
|
+
extra, duplicate or stale rows refuse the entire phase. Failed waves stop new
|
|
59
|
+
dispatch and join before a response; the native watchdog remains the hard
|
|
60
|
+
whole-helper bound for blocked calls. Descendant argv is discarded after
|
|
61
|
+
hashing. Already-exited captured descendants remain recorded as gone rather
|
|
62
|
+
than being adopted by PID.
|
|
63
|
+
|
|
64
|
+
Each phase separately hashes at most 64 executable files, 256 MiB per file and
|
|
65
|
+
512 MiB total. Read handles deny writes/deletion until the phase finishes.
|
|
66
|
+
Every process image path is reopened and matched by volume/file identity before
|
|
67
|
+
reusing a digest; paths alone and prior-phase digests are not cache keys.
|
|
68
|
+
|
|
69
|
+
The caller must independently verify the exact known legacy package, selected
|
|
70
|
+
launcher/task ownership, source hashes, and config-directory nonce/revision.
|
|
71
|
+
Matching process arguments and captured script hashes do **not** make an arbitrary
|
|
72
|
+
script an approved legacy package. Cwd is not a supported config binding.
|
|
73
|
+
|
|
74
|
+
Prepare is read-only. It captures the roots and a bounded set of currently
|
|
75
|
+
provable descendants **before any stop**. Each ancestry edge is checked against
|
|
76
|
+
a held, still-live parent and child creation time. Script read handles deny
|
|
77
|
+
script modification/deletion during this session. The plan contains:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
protocol: 1
|
|
81
|
+
kind: "legacy-observed-process-set"
|
|
82
|
+
roots: { supervisor, bridge }
|
|
83
|
+
observed: [captured identities]
|
|
84
|
+
excluded: [proven older nonmembers; never termination targets]
|
|
85
|
+
observedSetSha256
|
|
86
|
+
treeCompleteness: "unproven"
|
|
87
|
+
scope
|
|
88
|
+
planSha256
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Identities include PID, creation time, parent PID/creation time, owner SID, image
|
|
92
|
+
path/hash, argv hash and root script hash. No opaque commit token is in `plan`.
|
|
93
|
+
Passing a previous full `plan` as `expected` requires exact equality, including
|
|
94
|
+
all root generations, hashes and observed members. Changes require a new plan
|
|
95
|
+
and confirmation; no new set is silently substituted.
|
|
96
|
+
|
|
97
|
+
If a query returns a candidate whose actual creator PID equals a still-live held
|
|
98
|
+
parent, but whose creation time predates that parent's held generation, it is
|
|
99
|
+
excluded with PID/birth/parent-birth evidence and reason `predates-held-parent`.
|
|
100
|
+
Only query/synchronize access is opened for this check; an excluded candidate
|
|
101
|
+
never receives terminate access and is not traversed. This handles older
|
|
102
|
+
survivors whose creator's numeric PID was reused. The plan digest binds the
|
|
103
|
+
excluded evidence too. A dead parent, changed live edge, inaccessible query or
|
|
104
|
+
other ambiguous ownership still refuses. Stop rechecks any excluded candidate
|
|
105
|
+
still returned by the same rooted query; changed generations require replanning.
|
|
106
|
+
|
|
107
|
+
Cached identities repeat the native generation, parent, owner and liveness
|
|
108
|
+
checks before reuse. If a selected root predates a newer held generation of its
|
|
109
|
+
numeric creator PID, only that false descendant edge is ignored. The root
|
|
110
|
+
remains selected, its parent-birth binding is not rewritten, and it is not added
|
|
111
|
+
to `excluded`. Non-root or unproven cached edges still refuse. Revalidation uses
|
|
112
|
+
the same classification; depth eight and the combined identity limit do not
|
|
113
|
+
change.
|
|
114
|
+
|
|
115
|
+
Only after explicit per-plan interruption/uncertain-HTTP confirmation and holding
|
|
116
|
+
and rechecking the **exact selected** autostart registration may the caller invoke
|
|
117
|
+
`stop()`. This broker does not attest that the user has paused callers or held the
|
|
118
|
+
task. It rechecks roots, listener, argv/image identity and currently reachable
|
|
119
|
+
members. A newly observed child before commit causes refusal, not silent expansion.
|
|
120
|
+
Then it terminates the selected supervisor, bridge, and admitted descendants
|
|
121
|
+
through their retained native handles, never by a fresh PID lookup.
|
|
122
|
+
|
|
123
|
+
All root/capture ambiguities refuse. After mutation starts, failures are explicit
|
|
124
|
+
and may be partial; never automatically relaunch the old supervisor, replay
|
|
125
|
+
tools, or broaden cleanup. The caller must preserve its task hold and journal
|
|
126
|
+
when stop is uncertain.
|
|
127
|
+
|
|
128
|
+
### Failure diagnostics
|
|
129
|
+
|
|
130
|
+
The existing refusal envelope keeps its exception type, native error code,
|
|
131
|
+
stage, reason and `mutationStarted` flag. When available, `captureFailure`
|
|
132
|
+
contains fixed API/substage labels and nullable stored PID, creation, parent,
|
|
133
|
+
liveness and owner-match facts. An initial query-handle denial reports the
|
|
134
|
+
requested PID but `generationKnown: false` and `creationTime: null`. Existing
|
|
135
|
+
ancestry-refusal details are retained. Other call sites can report an API with
|
|
136
|
+
unknown substage/target; unknown fields are not inferred from a PID or exception.
|
|
137
|
+
|
|
138
|
+
`observation: "stored-observations-not-fresh-proof"` identifies these as prior
|
|
139
|
+
observations, not a new identity check or stop authority. No diagnostic recovery
|
|
140
|
+
query, retry, stronger access, argv, image path, SID or raw exception text is
|
|
141
|
+
added. Metadata is attached to the original exception, separately for each
|
|
142
|
+
operation/argv worker; collection failures leave the original refusal intact.
|
|
143
|
+
Success frames, plans, digests, approvals and stop behavior do not change.
|
|
144
|
+
|
|
145
|
+
## Honest receipt
|
|
146
|
+
|
|
147
|
+
```text
|
|
148
|
+
legacyRootStopVerified: true|false
|
|
149
|
+
observedDescendantsStopped: true|false
|
|
150
|
+
treeCompleteness: "unproven"
|
|
151
|
+
observedCount
|
|
152
|
+
excludedCount
|
|
153
|
+
planSha256
|
|
154
|
+
errors: [...]
|
|
155
|
+
unattributed: "Not discovered or not provable; left untouched."
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
There is no `stopped:true` or `activeProcesses:0` field. A wrapper that exits
|
|
159
|
+
before capture can leave an unprovable descendant. Matching snapshots and empty
|
|
160
|
+
captured sets do not prove no such processes exist. This broker neither counts
|
|
161
|
+
nor kills historical/unattributed orphans.
|
|
162
|
+
|
|
163
|
+
`verifyLegacyProcessesGone(plan)` validates the protected plan's shape/digest,
|
|
164
|
+
then opens **only its captured IDs** with query/synchronize access. A different
|
|
165
|
+
creation time is an unrelated reused PID and is untouched; a matching live
|
|
166
|
+
identity keeps the receipt false; inaccessible identities keep it unverified.
|
|
167
|
+
It never requests terminate access or performs discovery. The journal's
|
|
168
|
+
authentication/ownership and phase authorization belong to the caller. A hash
|
|
169
|
+
inside the plan is an integrity check, not an authentication credential.
|
|
170
|
+
|
|
171
|
+
Excluded candidates are never opened by recovery verification and are never
|
|
172
|
+
required to exit. Counts describe this bounded observation, not all historical
|
|
173
|
+
orphans or complete tree coverage.
|
|
174
|
+
|
|
175
|
+
## Bounds and lifecycle
|
|
176
|
+
|
|
177
|
+
- At most 512 **combined held and excluded identities**, including both roots,
|
|
178
|
+
and eight ancestry levels. Any count/depth/byte/query failure refuses the
|
|
179
|
+
whole plan; no subset is silently accepted.
|
|
180
|
+
- At most 48 argv queries during capture and 32 during revalidation.
|
|
181
|
+
- Initial capture: 15-second native watchdog, 20-second Node response bound.
|
|
182
|
+
- Uncommitted session: 120 seconds; expiration requires fresh prepare.
|
|
183
|
+
- Stop revalidation: 15 seconds; termination/waits share **one eight-second**
|
|
184
|
+
budget. Node's whole stop exchange is bounded to 30 seconds.
|
|
185
|
+
- Close/exit verification: three seconds.
|
|
186
|
+
- Plans: at most 240 KiB UTF-8 before emit/accept. Complete encoded requests:
|
|
187
|
+
at most 256 KiB; no full command lines or config contents emitted.
|
|
188
|
+
- Native parent watch: caller loss before commit releases handles without target
|
|
189
|
+
termination. After commit the already-authorized bounded stop finishes.
|
|
190
|
+
|
|
191
|
+
Prepare/stop responses include bounded phase timing/resource diagnostics and
|
|
192
|
+
the broker's own PID/FILETIME for owned exit evidence. These values confer no
|
|
193
|
+
target authority and are not part of the plan digest. Termination order and its
|
|
194
|
+
single shared eight-second budget are unchanged. A test-only outer cleanup job
|
|
195
|
+
is not proof that the production stop succeeded.
|
|
196
|
+
|
|
197
|
+
Detached native launch avoids inherited console/Node child termination, but is
|
|
198
|
+
not an unbounded daemon. Parent loss and deadlines end the broker. Native target
|
|
199
|
+
handles are non-inherited. No elevation or privilege enablement is attempted.
|
|
200
|
+
|
|
201
|
+
Both native launches (prepare and verify-only) first compare actual executable
|
|
202
|
+
SHA256 to packaged metadata. This detects package drift, not malicious replacement
|
|
203
|
+
of executable plus metadata or the whole package.
|
|
204
|
+
It is not an atomic executable-replacement defense; supported upgrades stage
|
|
205
|
+
separate immutable package roots rather than replacing a running helper in place.
|
|
206
|
+
|
|
207
|
+
## Build, inventory and tests
|
|
208
|
+
|
|
209
|
+
```powershell
|
|
210
|
+
.\tools\windows-legacy\build.ps1
|
|
211
|
+
.\tools\windows-legacy\build.ps1 -Verify
|
|
212
|
+
node --test test/windows-legacy-process.test.mjs
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The build reuses the installed recorded Roslyn and .NET Framework 4.6.2 reference
|
|
216
|
+
assemblies, including built-in `System.Management` and `System.Web.Extensions`.
|
|
217
|
+
It compiles twice under different owned paths and compares bytes, records source,
|
|
218
|
+
build script, compiler and reference hashes, and installs nothing. `-Verify`
|
|
219
|
+
updates no artifact. `inventory.mjs` checks the exact separate asset layout.
|
|
220
|
+
The immutable historical security helper and modern lifetime binaries stay
|
|
221
|
+
unchanged.
|
|
222
|
+
|
|
223
|
+
Dedicated tests use a synthetic argument-compatible supervisor for bounded
|
|
224
|
+
native identity cases. The upgrader owns the additional byte-exact known-1.3
|
|
225
|
+
package/launcher/config/task E2E; passing these focused tests does not replace it.
|
|
@@ -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,90 @@
|
|
|
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 cached selected-root creator-PID reuse skips only the older edge and retains strict refusals',
|
|
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 captures held roots and stops only its observed set with partial receipt',
|
|
32
|
+
'legacy broker close is read-only and an identical expected plan can be reopened',
|
|
33
|
+
'legacy broker preserves an already-exited captured descendant without adopting a replacement PID',
|
|
34
|
+
'legacy broker rejects stale generation or expanded observed plan without stopping roots',
|
|
35
|
+
'legacy broker rejects wrong root and malformed protected verification plan',
|
|
36
|
+
'legacy broker refuses extra supervisor argv and a foreign listener without process mutation',
|
|
37
|
+
'legacy read-only recovery does not stop an unrelated process reusing the endpoint',
|
|
38
|
+
'legacy native inventory rejects changed executable, source and build metadata',
|
|
39
|
+
'legacy prepare and recovery verify refuse wrong-hash packaged helper before any discovery',
|
|
40
|
+
'legacy broker parent loss releases read-only capture without stopping legacy roots',
|
|
41
|
+
'legacy broker reports captured descendant termination failure without certifying stop',
|
|
42
|
+
'legacy broker preserves Unicode plan identity across fragmented UTF8 pipe messages',
|
|
43
|
+
'legacy broker excludes a proven older nonmember without terminating or verifying its exit',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
export function verifyLegacyBrokerAssets(root) {
|
|
47
|
+
for (const path of ['bin', 'bin/windows-legacy', 'bin/windows-legacy/src',
|
|
48
|
+
'tools', 'tools/windows-legacy']) {
|
|
49
|
+
const stat = lstatSync(join(root, path));
|
|
50
|
+
assert.ok(stat.isDirectory() &&
|
|
51
|
+
!stat.isSymbolicLink(), `Linked or invalid legacy broker directory: ${path}`);
|
|
52
|
+
}
|
|
53
|
+
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
54
|
+
const files = LEGACY_BROKER_FILES.map((path) => {
|
|
55
|
+
const full = join(root, path);
|
|
56
|
+
const stat = lstatSync(full);
|
|
57
|
+
assert.ok(stat.isFile() &&
|
|
58
|
+
!stat.isSymbolicLink() &&
|
|
59
|
+
stat.size > 0, `Invalid legacy broker asset: ${path}`);
|
|
60
|
+
return { path, sha256: hash(readFileSync(full)) };
|
|
61
|
+
});
|
|
62
|
+
assert.deepEqual(readdirSync(join(root, 'bin/windows-legacy')).sort(), [
|
|
63
|
+
'LegacyProcessBroker.build.json', 'LegacyProcessBroker.exe', 'src',
|
|
64
|
+
]);
|
|
65
|
+
const sources = ['AssemblyInfo.cs', 'LegacyNative.cs', 'LegacyProcessBroker.cs'];
|
|
66
|
+
assert.deepEqual(readdirSync(join(root, 'bin/windows-legacy/src')).sort(), sources);
|
|
67
|
+
const metadata = JSON.parse(readFileSync(join(root, 'bin/windows-legacy/LegacyProcessBroker.build.json')));
|
|
68
|
+
assert.equal(metadata.schemaVersion, 1);
|
|
69
|
+
assert.equal(metadata.binary, 'LegacyProcessBroker.exe');
|
|
70
|
+
assert.equal(metadata.inputs.targetFramework, '.NETFramework,Version=v4.6.2');
|
|
71
|
+
assert.equal(metadata.inputs.platform, 'AnyCPU');
|
|
72
|
+
assert.deepEqual(Object.keys(metadata.inputs.sourceSha256).sort(), sources);
|
|
73
|
+
assert.deepEqual(Object.keys(metadata.inputs.referenceSha256).sort(),
|
|
74
|
+
['System.Core.dll', 'System.Management.dll', 'System.Web.Extensions.dll', 'System.dll', 'mscorlib.dll']);
|
|
75
|
+
assert.match(metadata.inputs.compilerSha256, /^[a-f0-9]{64}$/);
|
|
76
|
+
assert.deepEqual(metadata.inputs.compilerArguments, [
|
|
77
|
+
'/nologo', '/noconfig', '/nostdlib+', '/target:exe', '/platform:anycpu', '/langversion:7.3',
|
|
78
|
+
'/optimize+', '/debug-', '/deterministic+', '/checked-', '/unsafe-', '/warn:4', '/warnaserror+', '/utf8output',
|
|
79
|
+
]);
|
|
80
|
+
assert.equal(files.find((file) => file.path.endsWith('.exe')).sha256, metadata.binarySha256);
|
|
81
|
+
const inputs = sources.map((name) => [`bin/windows-legacy/src/${name}`, metadata.inputs.sourceSha256[name]]);
|
|
82
|
+
inputs.push(['tools/windows-legacy/build.ps1', metadata.inputs.buildScriptSha256]);
|
|
83
|
+
for (const [path, expected] of inputs) {
|
|
84
|
+
assert.equal(hash(readFileSync(join(root, path), 'utf8').replaceAll('\r\n', '\n')), expected,
|
|
85
|
+
`Changed legacy broker input: ${path}`);
|
|
86
|
+
}
|
|
87
|
+
return { status: 'source-and-binary-hashes-verified', files,
|
|
88
|
+
binarySha256: metadata.binarySha256, compilerSha256: metadata.inputs.compilerSha256,
|
|
89
|
+
reproducibilityBuild: 'requires-recorded-toolchain-verify' };
|
|
90
|
+
}
|
|
@@ -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.
|