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.
Files changed (80) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/CONTRIBUTING.md +39 -13
  3. package/README.md +255 -8
  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-task.ps1 +301 -0
  9. package/autostart/windows/register-task.ps1 +11 -2
  10. package/bin/account-task-adapter.mjs +47 -0
  11. package/bin/account-upgrade-controller.mjs +198 -0
  12. package/bin/account-upgrade-worker.mjs +134 -0
  13. package/bin/account-worker-session.mjs +215 -0
  14. package/bin/cli-dispatch.mjs +154 -0
  15. package/bin/cli-main.mjs +1007 -0
  16. package/bin/cli-selection.mjs +43 -0
  17. package/bin/cli.mjs +7 -876
  18. package/bin/legacy-1.3.json +32 -0
  19. package/bin/legacy-installation.mjs +217 -0
  20. package/bin/legacy-task.mjs +52 -0
  21. package/bin/legacy-upgrade.mjs +471 -0
  22. package/bin/managed-runtime.mjs +170 -0
  23. package/bin/managed-state.mjs +257 -0
  24. package/bin/managed-upgrade.mjs +491 -0
  25. package/bin/mcp-bridge.mjs +132 -4
  26. package/bin/pooling-batches.mjs +13 -0
  27. package/bin/pooling-writer.mjs +4 -0
  28. package/bin/prewarm.mjs +33 -21
  29. package/bin/runtime-context.mjs +100 -0
  30. package/bin/service-control.mjs +27 -4
  31. package/bin/stable-launcher.mjs +54 -0
  32. package/bin/task-only-controller.mjs +226 -0
  33. package/bin/task-scope.mjs +65 -0
  34. package/bin/task-transaction-protocol.mjs +173 -0
  35. package/bin/top.mjs +22 -10
  36. package/bin/update-check.mjs +13 -2
  37. package/bin/upgrade-autostart.mjs +47 -0
  38. package/bin/upgrade-capability.json +8 -0
  39. package/bin/upgrade-dependencies.mjs +122 -0
  40. package/bin/upgrade-package.mjs +203 -0
  41. package/bin/windows-legacy/LegacyProcessBroker.build.json +42 -0
  42. package/bin/windows-legacy/LegacyProcessBroker.exe +0 -0
  43. package/bin/windows-legacy/src/AssemblyInfo.cs +8 -0
  44. package/bin/windows-legacy/src/LegacyNative.cs +783 -0
  45. package/bin/windows-legacy/src/LegacyProcessBroker.cs +841 -0
  46. package/bin/windows-legacy-process.mjs +241 -0
  47. package/bin/windows-lifetime/ProcessLifetimeHelper.build.json +38 -0
  48. package/bin/windows-lifetime/ProcessLifetimeHelper.exe +0 -0
  49. package/bin/windows-lifetime/src/AssemblyInfo.cs +8 -0
  50. package/bin/windows-lifetime/src/ProcessLifetimeHelper.cs +468 -0
  51. package/bin/windows-process-lifetime.mjs +218 -0
  52. package/bin/windows-task-channel/TaskChannelGuard.build.json +45 -0
  53. package/bin/windows-task-channel/TaskChannelGuard.exe +0 -0
  54. package/bin/windows-task-channel/src/AssemblyInfo.cs +8 -0
  55. package/bin/windows-task-channel/src/ChannelCliContext.cs +210 -0
  56. package/bin/windows-task-channel/src/ChannelFiles.cs +231 -0
  57. package/bin/windows-task-channel/src/ChannelNative.cs +477 -0
  58. package/bin/windows-task-channel/src/ChannelSession.cs +203 -0
  59. package/bin/windows-task-channel/src/TaskChannelGuard.cs +686 -0
  60. package/bin/windows-task-channel.mjs +335 -0
  61. package/docs/account-upgrades.md +205 -0
  62. package/docs/execution-context.md +113 -0
  63. package/package.json +12 -5
  64. package/supervisor/supervise.mjs +226 -40
  65. package/tools/account-upgrade/qualify-two-account.ps1 +66 -0
  66. package/tools/windows-legacy/README.md +225 -0
  67. package/tools/windows-legacy/build.ps1 +122 -0
  68. package/tools/windows-legacy/inventory.mjs +90 -0
  69. package/tools/windows-process-lifetime/README.md +119 -0
  70. package/tools/windows-process-lifetime/build.ps1 +122 -0
  71. package/tools/windows-process-lifetime/inventory.mjs +86 -0
  72. package/tools/windows-task-channel/README.md +329 -0
  73. package/tools/windows-task-channel/build.ps1 +107 -0
  74. package/tools/windows-task-channel/inventory.mjs +117 -0
  75. package/ui/dist/THIRD_PARTY_NOTICES.txt +1 -28
  76. package/ui/dist/assets/{index-CgPruWYi.js → index-CcwMo2sI.js} +1 -1
  77. package/ui/dist/assets/index-PVcuDNrN.css +1 -0
  78. package/ui/dist/index.html +2 -2
  79. package/ui/dist/third-party-manifest.json +37 -25
  80. package/ui/dist/assets/index-CaLPg0c-.css +0 -1
@@ -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
+ }
@@ -0,0 +1,329 @@
1
+ # Windows task channel guard
2
+
3
+ This is a separate, **query-only** boundary for one task-hosted original-account
4
+ worker. It does not stop processes, change tokens, impersonate, enable privileges,
5
+ register tasks, install accounts, elevate, repair ACLs, or enumerate processes.
6
+ The existing same-user legacy broker and modern lifetime helper are unchanged.
7
+
8
+ ## Interface
9
+
10
+ `bin/windows-task-channel.mjs` verifies the packaged executable hash before **every
11
+ role**, then directly starts the helper on private inherited standard streams.
12
+ The helper derives and holds its actual parent; a request cannot select a PID.
13
+ It first emits a separate `started` event with its **own** PID/FILETIME for
14
+ exit tracking. That event is not readiness, caller authorization or worker proof.
15
+
16
+ ```js
17
+ const caller = await queryTaskChannelCaller();
18
+ const trust = await inspectTrustedCodeRoot(controllerPackageRoot);
19
+ const channel = await createTaskChannel({
20
+ operationId, targetSid, sessionId, manifestParent,
21
+ bootstrap: { nodePath, nodeSha256, scriptPath, scriptSha256 },
22
+ manifest: { /* public canonical operation binding and ephemeral public key */ },
23
+ });
24
+ const peer = await channel.awaitWorker();
25
+ const { peer: observed, frame } = await channel.receive();
26
+ await channel.send(response);
27
+ const authorization = await channel.authorize();
28
+ await channel.close();
29
+ ```
30
+
31
+ `channel` exposes `manifestPath`, `endpoint`, `controllerIdentity`, `actorFacts`,
32
+ `guardIdentity`, `targetSession`, `workerSession`, `pid`, `awaitWorker`, `send`, `receive`, `authorize`, `closed`,
33
+ and `close`. Returned objects are detached copies, never native state.
34
+ `closed` resolves with native process exit code/signal/reason. Every pending
35
+ operation fails when the channel becomes invalid. `close` releases proof only:
36
+ it never signals either parent or worker. The caller retains responsibility
37
+ for journal-driven removal of its exact, newly-created operation directory.
38
+ Query failures include `guardIdentity`, `helperExit` when observed, and
39
+ `cleanupUnverified`. If exit cannot be verified, the error keeps that fact and
40
+ the close error; it is never silently replaced by a generic readiness timeout.
41
+
42
+ The helper exclusively creates `<manifestParent>/<operationId>/manifest.json`.
43
+ An existing operation directory is refused, never reused or repaired.
44
+ Default exact worker arguments, after the native Node executable, are:
45
+
46
+ ```
47
+ <absolute scriptPath> --manifest <absolute manifestPath> --operation <UUID> --controller-root <manifestParent>
48
+ ```
49
+
50
+ Optional `bootstrap.argv` must equal that sequence. Native script/executable
51
+ read handles pin the verified bytes for the operation. Only local drive-absolute
52
+ paths are supported; relative, device, network and reparse paths are refused.
53
+ `expectedCreationTime`, when supplied, further restricts the observed peer birth;
54
+ it never selects a PID or permits reconnecting.
55
+
56
+ The manifest contains `protocol:1`, `operationId`, `endpoint`, `targetSid`,
57
+ `sessionId`, `targetSession`, `bootstrap`, `controllerIdentity`, `actorFacts`, and `document`
58
+ (the supplied public `manifest`). **No private key belongs in this document.**
59
+
60
+ The worker calls:
61
+
62
+ ```js
63
+ const { document, readerIdentity, protection } =
64
+ await readProtectedManifest(manifestPath, { operationId, manifestParent });
65
+ ```
66
+
67
+ The reader validates the exact operation path, OS file owner, restrictive
68
+ directory/file security and no-redirect path chain, and its **own actual parent
69
+ Node** image, argv, SID, session and FILETIME. It also freshly verifies that the
70
+ bound session still belongs to that SID and the manifest's logon generation,
71
+ returning `targetSession` separately from the protected manifest snapshot.
72
+ It does not query the elevated
73
+ controller's token or threads: an ordinary other-user reader usually lacks
74
+ those rights. Its trust anchor is the controller-owned protected manifest and
75
+ bootstrap, not a self-reported admin field. `protection.actorOwnerSid` comes from
76
+ the OS descriptor. Own-user operation explicitly reports
77
+ `same-user-not-self-tamper-proof`; it does not protect against its own user.
78
+
79
+ ## Identity and authorization
80
+
81
+ The helper owns exactly one local pipe with `FILE_FLAG_FIRST_PIPE_INSTANCE`,
82
+ `PIPE_REJECT_REMOTE_CLIENTS` and maximum one instance. A **direct Node worker**
83
+ connects: no proxy is treated as the worker. `GetNamedPipeClientProcessId` and
84
+ `GetNamedPipeClientSessionId` supply the peer, then query/synchronize process and
85
+ query-token handles remain open. The identity contains:
86
+
87
+ - `pid`, exact decimal FILETIME `creationTime`, `ownerSid`, `sessionId`
88
+ - `imagePath`, `imageSha256`, and `argvSha256`
89
+
90
+ Generation-bound WMI queries inspect only the guard's parent link or the
91
+ selected process's argv. Parent-thread IDs come from process-scoped PSS
92
+ snapshots, not WMI. There is no name/PID sweep or generic PID RPC.
93
+ Received JSON remains under `frame`; peer-supplied identity/type fields cannot
94
+ replace the separately generated `peer` envelope. Disconnection, peer exit,
95
+ parent exit, identity ambiguity or a deadline invalidates the operation.
96
+ No reconnect or replacement peer is accepted.
97
+
98
+ Controller facts come from the actual held parent's primary token: elevation,
99
+ enabled (not deny-only) Administrators SID, restrictions, AppContainer and
100
+ SID/session. Two process-scoped `PSS_CAPTURE_THREADS` (`0x80`, context flags zero)
101
+ captures retain a complete 1–128 unique-entry set, including PSS termination
102
+ metadata. Each thread is separately held with query/synchronize rights. Live
103
+ threads require matching owner PID, birth, liveness and token checks on both
104
+ observations. A PSS-terminated entry is accepted as already ended only when
105
+ supported flags and both snapshots agree with its held owner/birth/exit, its
106
+ exit strictly predates the first observation, and its held handle is signaled.
107
+ Ended handles remain held through the second observation. At least one live
108
+ thread is required. A live thread ending during observation still refuses.
109
+ The raw count/bounds include ended entries; no missing or partial set is accepted.
110
+ The ID sets, classifications and held
111
+ generations must remain unchanged. Missing/inaccessible primary or thread facts,
112
+ churn and observed thread impersonation refuse full caller/serve/authorize roles,
113
+ including same-user calls; they cannot become partial successful authorization.
114
+ `observed-none` is a **bounded observation, not an atomic
115
+ promise about future thread impersonation**. The guard's own thread fact is
116
+ separate. No controller thread ID is guessed from Node or Task Scheduler.
117
+
118
+ Only `cli-context`, `caller` and `serve` open their actual controller parent with
119
+ `PROCESS_QUERY_INFORMATION | PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE`
120
+ (`0x101400`), as required by the existing PSS capture. Authorization reuses that
121
+ held parent handle but repeats the complete observations, not cached facts.
122
+ Peer, manifest-reader, inspect-root and other process opens retain `0x101000`.
123
+ Denied query access refuses without fallback, privilege changes or permission
124
+ repair. The ordinary CLI and full-facts roles share ID capture, not eligibility
125
+ or administrator policy.
126
+
127
+ A different target SID requires actual elevated/enabled-admin, unrestricted,
128
+ non-AppContainer and nonimpersonating observations. Own-user query-only operation
129
+ requires no elevation. `authorize()` repeats actual parent/token/thread
130
+ observations; the task controller must call it and enforce its role immediately
131
+ before **each** permitted task mutation. It is available before the peer connects
132
+ for the preapproved bootstrap task operations, without opening a second helper.
133
+ Persisted facts and supplied booleans
134
+ never confer authority.
135
+
136
+ After the token/thread query, before acknowledging authorization, the guard
137
+ rechecks its held peer process/current SID/session, queries the **same pipe**
138
+ with `PeekNamedPipe`, and checks operation/framing/handshake deadlines. A peer
139
+ that exits or disconnects during that query cannot receive a stale successful
140
+ acknowledgement. This is still a point-in-time observation, not a promise that
141
+ the worker can never exit after the reply.
142
+
143
+ `inspectTrustedCodeRoot` checks root ancestors and all code-tree entries without
144
+ importing them. Allowed owners/writers are the actual actor, SYSTEM,
145
+ Administrators and the Windows TrustedInstaller service SID. Unknown write,
146
+ delete, ownership or permission authority is refused, even if a deny ACE might
147
+ make it harmless. Ancestor creation of an unrelated child is not deletion of an
148
+ existing protected child. Held no-delete path handles and native reparse checks
149
+ avoid following a replacement path. This is conservative admission, not a
150
+ general Windows effective-access evaluator or loaded-code attestation.
151
+ Only this read-only role omits the unused parent-thread observations and
152
+ reports `parentThreadImpersonation: "unverified"` with
153
+ `parentThreadQueryError: "NOT_OBSERVED_READ_ONLY_ROLE"`. It still queries the
154
+ held parent's primary token/SID and all root ACLs, with facts captured after
155
+ the completed walk. No caller flag selects this behavior. Caller/serve/authorize
156
+ roles retain their existing complete-observation requirement for admin authority.
157
+
158
+ ## Application authentication stays outside this helper
159
+
160
+ The Node controller keeps its ephemeral private key **only in memory**. It signs
161
+ a domain-separated canonical operation digest, fresh worker challenge, this
162
+ guard's observed PID/birth/SID/session, and sequence. The worker verifies the
163
+ signature using the protected manifest key and compares its own native identity.
164
+ The same held pipe carries the exchange and later messages.
165
+
166
+ The guard does not implement these signatures or task verbs. The application
167
+ must not treat `awaitWorker()` alone as mutual authentication. Task `RunEx`
168
+ success/instance GUID/EnginePID, argv text, a nonce, or a copied JSON identity
169
+ is not loaded-code attestation. The elevated controller must remain safe even
170
+ against a lying authenticated worker: only preapproved task verbs and fixed
171
+ operation/sequence/state digests, with controller-chosen paths and definitions.
172
+
173
+ ### Read state and cancellation
174
+
175
+ Peer reads decode UTF-8 and validate the outer JSON **object** in their own
176
+ bounded read task. The final authorization check observes any completed read
177
+ failure before emitting an acknowledgement. A valid object remains queued for
178
+ normal delivery exactly once; encoded/opaque values inside that object are not
179
+ interpreted as commands. Invalid UTF-8, invalid JSON and non-object frames cannot
180
+ be acknowledged merely because the peer process and pipe are still alive.
181
+
182
+ A separate continuous stdin reader keeps close/EOF/protocol failure observable
183
+ while a command is executing, including while authorization is blocked in token
184
+ or session queries. It queues at most 32 parsed commands and executes them in
185
+ order. Command IDs start at 1 and increase by one; duplicate/out-of-order IDs,
186
+ unknown commands and overflow refuse the channel. Close and EOF are latched
187
+ independently of queued authorization requests. The existing cancellation
188
+ watchdog/deadlines remain unchanged; there is no unbounded read queue or reconnect.
189
+
190
+ Owned tests compile a barrier into a private copy of `Authorize`, confirm the
191
+ peer read is faulted before release, then assert no authorization event. Separate
192
+ pre/post-peer close and EOF tests keep the parent alive and the watchdog enabled.
193
+ Queued-authorization cancellation, sequence/overflow refusal and valid opaque
194
+ FIFO delivery have their own controls. No barrier or bypass option is shipped.
195
+
196
+ ## Bound interactive-session eligibility
197
+
198
+ Before creating bootstrap resources, before reporting a verified worker, and
199
+ on **every** authorization, the helper queries only the operation's fixed local
200
+ session. It uses `WTSQuerySessionInformationW` and `LookupAccountNameW`;
201
+ it does not enumerate sessions/users or call `WTSQueryUserToken`.
202
+
203
+ Full `WTSUserName`/`WTSDomainName` strings are resolved to a canonical **user SID**,
204
+ which must equal the selected SID. They are sampled twice, bracketed by
205
+ `WTSSessionInfoEx` snapshots with the same session ID, state and nonzero logon
206
+ FILETIME. The fixed-width extended-info name fields are consistency checks only;
207
+ a truncated 20-character name is **never** used for SID resolution. Permission
208
+ denial, missing/empty/unmapped identity, unsupported generation information,
209
+ changed samples, non-user SIDs and wrong ownership all cause refusal.
210
+
211
+ `active` and `disconnected` (still logged on without a connected client) are
212
+ eligible. Connected-but-not-logged-on, idle, listen, reset, down, initialization
213
+ and shadow states are not. The captured logon generation is fixed for this
214
+ operation; reauthorization cannot silently switch to a reused session.
215
+
216
+ The trusted `targetSession` shape is:
217
+
218
+ ```
219
+ { sessionId, ownerSid, state, logonTime, observedAt,
220
+ source: "wts-account-sid-snapshot", atomicRunExBinding: false }
221
+ ```
222
+
223
+ `ready.targetSession` and `document.targetSession` are initial observations.
224
+ After `awaitWorker()`, `channel.workerSession` is the separately trusted fresh
225
+ post-launch observation, not a peer frame field. `authorize().targetSession`
226
+ is fresh again. The reader also returns a fresh top-level `targetSession`.
227
+ The application must authenticate the handshake and freshly authorize before
228
+ importing target code or performing target work/stop operations.
229
+
230
+ Each session observation has a five-second bound. Other-session queries require
231
+ the existing WTS Query Information rights; refusal does not trigger permission
232
+ repair, logon, impersonation or privilege enabling.
233
+
234
+ **WTS snapshots cannot hold session ownership or bind it atomically to Task
235
+ Scheduler `RunEx`.** A logoff/reassignment can still happen after the last query
236
+ and before the task call. The application must report this snapshot/call limit,
237
+ recheck immediately before bootstrap, and require actual same-connection worker
238
+ SID/session/generation proof plus fresh session eligibility after launch and
239
+ before target work. No task result or stored session ID replaces these checks.
240
+
241
+ The runtime hash check catches packaged drift, not atomic replacement by a
242
+ same-user package writer. Updates must use immutable candidate directories;
243
+ there is no new trusted-launcher chain or on-the-fly compilation.
244
+
245
+ ## Ordinary CLI effective-context observation
246
+
247
+ `queryCliCallerContext()` is a separate fixed role for ordinary **current-user**
248
+ dispatch. It does not authorize elevated/cross-user task work and does not
249
+ replace `queryTaskChannelCaller`, `serve` or `authorize`.
250
+
251
+ The native role derives the caller from its fresh private stdin pipe using
252
+ `GetNamedPipeServerProcessId`. It cross-checks the existing NT basic-process
253
+ parent-slot pattern, exact returned size/self PID, parent birth and liveness.
254
+ The JS export also requires the returned caller PID to equal `process.pid`.
255
+ The installed SDK calls that NT slot `Reserved3`: this is an explicit Windows
256
+ compatibility limit, not a portable documented PPID contract. Unsupported layout
257
+ or binding refuses the observation. The pipe query runs before its next blocking
258
+ read. No supplied PID or mode boolean provides authority.
259
+
260
+ Like the full caller/serve roles, this role opens its actual parent with
261
+ `PROCESS_QUERY_INFORMATION | PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE`
262
+ (`0x101400`). Two bounded, process-scoped `PSS_CAPTURE_THREADS` (`0x80`) captures
263
+ with context flags zero retain raw thread identity and termination metadata.
264
+ This uses the same complete held-generation observation as full facts, including
265
+ the strict already-ended classification described above. Every live thread must
266
+ positively return `ERROR_NO_TOKEN` from `OpenThreadToken(TOKEN_QUERY)` twice;
267
+ the complete ID set, held generations and classifications must stay unchanged.
268
+ Primary SID/session and token facts must remain stable. Primary elevation,
269
+ enabled Administrators and guard-thread impersonation must be false.
270
+
271
+ No VM reads, thread-context or handle capture, global enumeration,
272
+ impersonation, privilege enabling or rights-expansion fallback are used.
273
+ Denial, token presence, churn or unknown information means explicit
274
+ noneligibility/refusal, never a primary-token-only shortcut. The failed limited
275
+ mask and successful query-information probes establish behavior on the owned
276
+ host, not a documented universal minimum-rights claim.
277
+
278
+ The result contains `proofScope: "cli-effective-context-snapshot"`,
279
+ `ordinaryEligible`, `reason`, caller `identity`, `actorFacts`, `guardIdentity`,
280
+ `helperExit`, and:
281
+
282
+ ```
283
+ observation: {
284
+ method: "pss-threads-held-token-query", processAccess: "0x101400",
285
+ captureFlags: "0x80", threadContextFlags: 0, threadCount,
286
+ completeStableThreadSet, primaryStable,
287
+ atomicFutureProtection: false, observedAt
288
+ }
289
+ ```
290
+
291
+ Only a complete positive result with verified helper exit supports the
292
+ non-elevated current-user path. Selected-instance JSON cannot supply that
293
+ decision. Verified noneligibility keeps strict checks; thrown errors, especially
294
+ `cleanupUnverified`, must not become fallback success. This is a **moment-in-time
295
+ complete observation**, not immunity to future thread creation/impersonation.
296
+ Consume it before importing target code; do not cache it as lasting authority.
297
+ Native-probe and role-roundtrip timings are not end-to-end CLI measurements.
298
+
299
+ ## Bounds and qualification
300
+
301
+ - Peer frames: 16 KiB UTF-8 including newline, JSON objects only; strict UTF-8,
302
+ no CR framing. A partial frame has five seconds after its first byte.
303
+ - Manifest/init/event bounds: 64 KiB; public document at most 48 KiB.
304
+ - Handshake: 2–30 seconds (default 30); total lifetime: 2–900 seconds
305
+ (default 900). Native startup/read-only queries have a 13-second bound, below
306
+ the unchanged 15-second JS readiness limit. A dedicated background thread
307
+ watches parent exit, stdin cancellation and deadlines even if WMI or file ACL
308
+ calls block the main thread. It exits **only this helper**. Root walks also
309
+ check cancellation/liveness between entries. A close/EOF gets a short 500 ms
310
+ grace for normal shutdown, not a longer operation deadline.
311
+ - One outstanding native peer read; JS queues at most 32 messages/requests.
312
+ Native pipe write deadline is one second.
313
+ - Thread set: 1–128, two bounded observations. Code-root scan: at most 2,048
314
+ directories/16,384 files. Bounds/inaccessible objects cause refusal.
315
+
316
+ Build with `tools/windows-task-channel/build.ps1`; `-Verify` checks recorded
317
+ compiler/reference/source inputs and two byte-identical clean compilations.
318
+ No downloads, installs, elevation or runtime compiler are used.
319
+
320
+ `test/windows-task-channel.test.mjs` uses only owned same-user processes/pipes,
321
+ 45-second expiry and captured PID+FILETIME cleanup receipts. Pure reflection
322
+ policy tests are labeled models and never bypass shipped native authorization.
323
+ Actual elevated **two-user** pipe/security/task/session behavior is not qualified
324
+ by those tests. Run it only in a separately authorized existing two-user test
325
+ environment; this work creates no accounts, credentials, logons or tasks.
326
+ Blocked-query tests compile an isolated, explicitly marked test variant with
327
+ a 30-second stand-in for a blocked root read. One failure-injection variant
328
+ also disables its watchdog to verify the JS exit-unverified error path. Neither
329
+ behavior is exposed by a shipped argument, environment variable or auth bypass.
@@ -0,0 +1,107 @@
1
+ [CmdletBinding()]
2
+ param([switch]$Verify, [string]$CompilerPath, [string]$ReferenceAssemblyPath)
3
+ Set-StrictMode -Version Latest
4
+ $ErrorActionPreference = 'Stop'
5
+
6
+ function Get-Digest([string]$Path, [switch]$Text) {
7
+ $bytes = [IO.File]::ReadAllBytes($Path)
8
+ if ($Text) { $bytes = [Text.Encoding]::UTF8.GetBytes([IO.File]::ReadAllText($Path).Replace("`r`n", "`n")) }
9
+ $hash = [Security.Cryptography.SHA256]::Create()
10
+ try { return [BitConverter]::ToString($hash.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant() }
11
+ finally { $hash.Dispose() }
12
+ }
13
+ if ($env:OS -ne 'Windows_NT') { throw 'Use installed Windows Roslyn and .NET Framework 4.6.2 references.' }
14
+ $root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
15
+ $directory = Join-Path $root 'bin\windows-task-channel'
16
+ $binary = 'TaskChannelGuard.exe'
17
+ $metadataPath = Join-Path $directory 'TaskChannelGuard.build.json'
18
+ $sources = @('AssemblyInfo.cs', 'ChannelCliContext.cs', 'ChannelFiles.cs', 'ChannelNative.cs', 'ChannelSession.cs', 'TaskChannelGuard.cs')
19
+ $references = @('mscorlib.dll', 'System.dll', 'System.Core.dll', 'System.Management.dll', 'System.Web.Extensions.dll')
20
+ if (-not $CompilerPath) {
21
+ $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
22
+ if (-not (Test-Path -LiteralPath $vswhere)) { throw 'Supply an installed -CompilerPath; no tools are installed.' }
23
+ $installation = & $vswhere -latest -products '*' -requires Microsoft.Component.MSBuild -property installationPath
24
+ if ($LASTEXITCODE -ne 0 -or
25
+ -not $installation) { throw 'Installed compiler missing.' }
26
+ $CompilerPath = Join-Path $installation 'MSBuild\Current\Bin\Roslyn\csc.exe'
27
+ }
28
+ if (-not $ReferenceAssemblyPath) {
29
+ $ReferenceAssemblyPath = Join-Path ${env:ProgramFiles(x86)} 'Reference Assemblies\Microsoft\Framework\.NETFramework\v4.6.2'
30
+ }
31
+ $CompilerPath = (Resolve-Path -LiteralPath $CompilerPath).Path
32
+ $ReferenceAssemblyPath = (Resolve-Path -LiteralPath $ReferenceAssemblyPath).Path
33
+ $framework = [xml][IO.File]::ReadAllText((Join-Path $ReferenceAssemblyPath 'RedistList\FrameworkList.xml'))
34
+ if ($framework.FileList.Name -cne '.NET Framework 4.6.2') { throw 'Wrong targeting pack.' }
35
+ $version = (& $CompilerPath /version | Out-String).Trim()
36
+ if ($LASTEXITCODE -ne 0) { throw 'Compiler version query failed.' }
37
+ $options = @('/nologo', '/noconfig', '/nostdlib+', '/target:exe', '/platform:anycpu',
38
+ '/langversion:7.3', '/optimize+', '/debug-', '/deterministic+', '/checked-',
39
+ '/unsafe-', '/warn:4', '/warnaserror+', '/utf8output')
40
+ $sourceHashes = [ordered]@{}
41
+ foreach ($name in $sources) { $sourceHashes[$name] = Get-Digest (Join-Path $directory "src\$name") -Text }
42
+ $referenceHashes = [ordered]@{}
43
+ foreach ($name in $references) { $referenceHashes[$name] = Get-Digest (Join-Path $ReferenceAssemblyPath $name) }
44
+ $inputs = [ordered]@{
45
+ targetFramework = '.NETFramework,Version=v4.6.2'
46
+ platform = 'AnyCPU'
47
+ compilerVersion = $version
48
+ compilerSha256 = Get-Digest $CompilerPath
49
+ compilerArguments = $options
50
+ pathMap = '/_/mcp-pacemaker/windows-task-channel'
51
+ sourceEncoding = 'UTF-8 without BOM, LF line endings'
52
+ sourceSha256 = $sourceHashes
53
+ buildScriptSha256 = Get-Digest $PSCommandPath -Text
54
+ referenceSha256 = $referenceHashes
55
+ }
56
+ $expected = $null
57
+ if ($Verify) {
58
+ $expected = [IO.File]::ReadAllText($metadataPath) | ConvertFrom-Json
59
+ if ($expected.schemaVersion -ne 1 -or
60
+ $expected.binary -cne $binary -or
61
+ (ConvertTo-Json $inputs -Depth 6 -Compress) -cne (ConvertTo-Json $expected.inputs -Depth 6 -Compress) -or
62
+ (Get-Digest (Join-Path $directory $binary)) -cne $expected.binarySha256) {
63
+ throw 'Task guard inputs/toolchain/bytes differ; Verify changes nothing.'
64
+ }
65
+ }
66
+ $temporary = Join-Path ([IO.Path]::GetTempPath()) ('mcp-task-channel-build-' + [Guid]::NewGuid().ToString('N'))
67
+ $files = [Collections.Generic.List[string]]::new()
68
+ $directories = [Collections.Generic.List[string]]::new()
69
+ $outputs = [Collections.Generic.List[string]]::new()
70
+ try {
71
+ $null = [IO.Directory]::CreateDirectory($temporary)
72
+ $directories.Add($temporary)
73
+ foreach ($pass in @('first', 'second')) {
74
+ $scratch = Join-Path $temporary $pass
75
+ $null = [IO.Directory]::CreateDirectory($scratch)
76
+ $directories.Add($scratch)
77
+ $paths = foreach ($name in $sources) {
78
+ $path = Join-Path $scratch $name
79
+ $files.Add($path)
80
+ $text = [IO.File]::ReadAllText((Join-Path $directory "src\$name")).Replace("`r`n", "`n")
81
+ [IO.File]::WriteAllText($path, $text, [Text.UTF8Encoding]::new($false))
82
+ $path
83
+ }
84
+ $output = Join-Path $scratch $binary
85
+ $files.Add($output)
86
+ $arguments = $options + @("/out:$output", "/pathmap:$scratch=$($inputs.pathMap)")
87
+ foreach ($name in $references) { $arguments += '/reference:' + (Join-Path $ReferenceAssemblyPath $name) }
88
+ & $CompilerPath @arguments @paths
89
+ if ($LASTEXITCODE -ne 0) { throw "Task guard compile failed: $pass" }
90
+ $outputs.Add($output)
91
+ }
92
+ $digest = Get-Digest $outputs[0]
93
+ if ($digest -cne (Get-Digest $outputs[1])) { throw 'Deterministic builds differ; no assets changed.' }
94
+ if ($Verify) {
95
+ if ($digest -cne $expected.binarySha256) { throw 'Rebuilt bytes differ; no assets changed.' }
96
+ Write-Output "Verified task guard source/toolchain/bytes: SHA256 $digest"
97
+ } else {
98
+ $metadata = [ordered]@{ schemaVersion = 1; binary = $binary; binarySha256 = $digest; inputs = $inputs }
99
+ [IO.File]::Copy($outputs[0], (Join-Path $directory $binary), $true)
100
+ [IO.File]::WriteAllText($metadataPath, (ConvertTo-Json $metadata -Depth 8).Replace("`r`n", "`n") + "`n",
101
+ [Text.UTF8Encoding]::new($false))
102
+ Write-Output "Built two identical task guards: SHA256 $digest"
103
+ }
104
+ } finally {
105
+ foreach ($path in $files) { Remove-Item -LiteralPath $path -ErrorAction SilentlyContinue }
106
+ for ($index = $directories.Count - 1; $index -ge 0; $index--) { [IO.Directory]::Delete($directories[$index]) }
107
+ }