mcp-pacemaker 2.0.1 → 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 +105 -1
- package/CONTRIBUTING.md +49 -13
- package/README.md +361 -60
- 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,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
|
+
}
|